diff --git a/README.md b/README.md index 7e6ca255d..ff52c71f8 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ By solving the networking and programability hurdles, Pub provides Lightning wit - [x] A management dashboard is actively being integrated into [ShockWallet](https://github.com/shocknet/wallet2) - [x] Nostr native [CLINK](https://clinkme.dev) "offers" - [x] Encrypted Push Notifications -- [_] Swap integration (in progress) +- [-] Swap integration (Partial, via admin dashboard only, quotes from Zeus LSP and Boltz) - [ ] P2P "LSP" coordination for channel batching over Nostr - [ ] High-Availabilty / Clustering @@ -288,14 +288,16 @@ The wizard interface (coming soon for Start9/Umbrel) will make this graphical. ### Bootstrap Liquidity Provider -By default, Lightning.Pub uses a bootstrap liquidity provider that provides initial channel funding as a service credit until you can afford your own channels. Pub compares rates from top LSPs and automatically requests a channel when needed. +By default, Lightning.Pub connects to a bootstrap liquidity provider (another Pub over Nostr) for outbound routing when local LND channel liquidity is low. Pub compares rates from top LSPs and automatically requests a channel when needed. + +Settings are loaded from environment variables or the `admin_settings` database table (env wins if both are set). The relevant keys are **`PROVIDER_NPROFILE`** and **`DISABLE_LIQUIDITY_PROVIDER`** ```bash -# Disable for full sovereignty +# Disable upstream provider checks (LND only for outbound payments) DISABLE_LIQUIDITY_PROVIDER=true -# Or point to a different Pub instance -LIQUIDITY_PROVIDER_PUB=nprofile1... +# Optional: point at a different Pub instance (pubkey + relay are in the nprofile) +PROVIDER_NPROFILE=nprofile1qyd8wumn8ghj7um5wfn8y7fwwd5x7cmt9ehx2arhdaexkqpqwmk5tuqvafa6ckwc6zmaypyy3af3n4aeds2ql7m0ew42kzsn638q9s9z8p ``` ### Custom Lightning Address Domain diff --git a/src/services/helpers/offerValidation.ts b/src/services/helpers/offerValidation.ts new file mode 100644 index 000000000..d841915f1 --- /dev/null +++ b/src/services/helpers/offerValidation.ts @@ -0,0 +1,5 @@ +export const assertValidOfferPriceSats = (priceSats: number): void => { + if (priceSats < 0) { + throw new Error("price_sats cannot be negative") + } +} diff --git a/src/services/helpers/safeOutboundFetch.ts b/src/services/helpers/safeOutboundFetch.ts index 9aabd190e..10771d4b3 100644 --- a/src/services/helpers/safeOutboundFetch.ts +++ b/src/services/helpers/safeOutboundFetch.ts @@ -63,6 +63,74 @@ export const isMetadataIp = (ip: string): boolean => { return false } +export const isLoopbackIPv4 = (ip: string): boolean => { + const parts = ip.split(".").map(Number) + if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) { + return false + } + return parts[0] === 127 +} + +export const isPrivateIPv4 = (ip: string): boolean => { + const parts = ip.split(".").map(Number) + if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) { + return false + } + const [a, b] = parts + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 169 && b === 254) return true + if (a === 100 && b >= 64 && b <= 127) return true + return false +} + +export const isLoopbackIPv6 = (ip: string): boolean => { + const normalized = ip.toLowerCase().split("%")[0] + if (normalized === "::1") return true + if (normalized.startsWith("::ffff:")) { + const mapped = normalized.slice("::ffff:".length) + if (mapped.includes(".")) { + return isLoopbackIPv4(mapped) + } + } + return false +} + +export const isPrivateIPv6 = (ip: string): boolean => { + const normalized = ip.toLowerCase().split("%")[0] + if (normalized.startsWith("::ffff:")) { + const mapped = normalized.slice("::ffff:".length) + if (mapped.includes(".")) { + return isPrivateIPv4(mapped) + } + } + const firstHextet = normalized.split(":")[0] + if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) return true + if ( + firstHextet.startsWith("fe8") || + firstHextet.startsWith("fe9") || + firstHextet.startsWith("fea") || + firstHextet.startsWith("feb") + ) { + return true + } + return false +} + +export const isBlockedCallbackIp = (ip: string): boolean => { + const version = isIP(ip) + if (version === 4) { + if (isLoopbackIPv4(ip)) return false + return isPrivateIPv4(ip) || isMetadataIPv4(ip) + } + if (version === 6) { + if (isLoopbackIPv6(ip)) return false + return isPrivateIPv6(ip) || isMetadataIPv6(ip) + } + return false +} + export const validateCallbackUrlForEgress = (url: URL): void => { if (url.protocol !== "http:" && url.protocol !== "https:") { throw new SafeOutboundFetchError("callback url protocol must be http or https") @@ -74,11 +142,7 @@ export const validateCallbackUrlForEgress = (url: URL): void => { if (blockedHostnames.has(host)) { throw new SafeOutboundFetchError("callback url hostname is not allowed") } - const ipVersion = isIP(host) - if (ipVersion === 4 && isMetadataIPv4(host)) { - throw new SafeOutboundFetchError("callback url resolves to a blocked address") - } - if (ipVersion === 6 && isMetadataIPv6(host)) { + if (isIP(host) !== 0 && isBlockedCallbackIp(host)) { throw new SafeOutboundFetchError("callback url resolves to a blocked address") } } @@ -113,7 +177,7 @@ const safeLookup = ( return } const records = (addresses as dns.LookupAddress[]).filter( - record => !isMetadataIp(record.address) + record => !isBlockedCallbackIp(record.address) ) if (records.length === 0) { const blocked = new SafeOutboundFetchError("callback url resolves to a blocked address") diff --git a/src/services/main/debitManager.ts b/src/services/main/debitManager.ts index c7294ae08..76bd8444a 100644 --- a/src/services/main/debitManager.ts +++ b/src/services/main/debitManager.ts @@ -11,9 +11,10 @@ import { Ndebit, NdebitData, NdebitFailure, NdebitSuccess, RecurringDebitTimeUni import { debitAccessRulesToDebitRules, newNdebitResponse, debitRulesToDebitAccessRules, nofferErrors, k1AlreadyProcessedReason, AuthRequiredRes, HandleNdebitRes, expirationRuleName, - frequencyRuleName, IntervalTypeToSeconds, unitToIntervalType + frequencyRuleName, IntervalTypeToSeconds, unitToIntervalType, ndebitFailure, + ValidateAccessRulesResult, } from "./debitTypes.js"; -import { decode as decodeBolt11 } from "light-bolt11-decoder"; +import PaymentManager from "./paymentManager.js"; type k1Info = { k1: string @@ -43,11 +44,13 @@ export class DebitManager { lnd: LND k1Debouncers: Record = {} interval: NodeJS.Timer + paymentManager: PaymentManager logger = getLogger({ component: 'DebitManager' }) - constructor(storage: Storage, lnd: LND, applicationManager: ApplicationManager) { + constructor(storage: Storage, lnd: LND, applicationManager: ApplicationManager, paymentManager: PaymentManager) { this.storage = storage this.lnd = lnd this.applicationManager = applicationManager + this.paymentManager = paymentManager this.StartDebounceCleaner() } @@ -163,8 +166,8 @@ export class DebitManager { const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const appUser = await this.storage.applicationStorage.GetApplicationUser(app, ctx.app_user_id) const validateResult = await this.validateAccessRules(access, app, appUser, invoice) - if (!validateResult) { - this.sendDebitResponse({ res: 'GFY', error: nofferErrors[1], code: 1 }, { pub: npub, id: request_id, appId: ctx.app_id }) + if (!validateResult.ok) { + this.sendDebitResponse(validateResult.failure, { pub: npub, id: request_id, appId: ctx.app_id }) return } this.logger("🔍 [DEBIT REQUEST] Sending debit payment") @@ -339,8 +342,8 @@ export class DebitManager { return { status: 'fail', debitRes: { res: 'GFY', error: nofferErrors[1], code: 1 } } } const validateResult = await this.validateAccessRules(authorization, app, appUser, bolt11) - if (!validateResult) { - return { status: 'fail', debitRes: { res: 'GFY', error: nofferErrors[1], code: 1 } } + if (!validateResult.ok) { + return { status: 'fail', debitRes: validateResult.failure } } this.logger("🔍 [DEBIT REQUEST] Sending requested debit payment") const { payment } = await this.sendDebitPayment(appId, appUserId, requestorPub, bolt11) @@ -353,51 +356,41 @@ export class DebitManager { return { payment } } - validateAccessRules = async (access: DebitAccess, app: Application, appUser: ApplicationUser, bolt11: string): Promise => { - const amt = decodeInvoiceAmount(bolt11) - if (amt === 0) { - return false + validateAccessRules = async (access: DebitAccess, app: Application, appUser: ApplicationUser, bolt11: string): Promise => { + const decoded = await this.lnd.DecodeInvoice(bolt11) + const amt = decoded.numSatoshis + if (amt <= 0) { + return { ok: false, failure: ndebitFailure(5) } } const { rules } = access if (!rules) { - return true + return { ok: true } } if (rules[expirationRuleName]) { const [expiration] = rules[expirationRuleName] - if (+expiration < Date.now()) { + if (+expiration < Math.floor(Date.now() / 1000)) { await this.storage.debitStorage.RemoveDebitAccess(access.app_user_id, access.npub) - return false + return { ok: false, failure: ndebitFailure(3) } } } if (rules[frequencyRuleName]) { + const isManaged = app.owner.user_id !== appUser.user.user_id + const expectedFee = this.paymentManager.getSendServiceFee(Types.UserOperationType.OUTGOING_INVOICE, amt, isManaged) const [number, unit, max] = rules[frequencyRuleName] const intervalType = unitToIntervalType(unit as RecurringDebitTimeUnit) const seconds = IntervalTypeToSeconds(intervalType) * (+number) const sinceUnix = Math.floor(Date.now() / 1000) - seconds const payments = await this.storage.paymentStorage.GetUserDebitPayments(appUser.user.user_id, sinceUnix, access.npub) - let total = amt + let total = amt + expectedFee for (const payment of payments) { total += payment.paid_amount + payment.service_fees } - if (total > +max) { - return false - } - } - return true - } -} - -const decodeInvoiceAmount = (bolt11: string): number => { - try { - const decoded = decodeBolt11(bolt11) - for (const section of decoded.sections) { - if (section.name === 'amount') { - // Amount is in millisatoshis - return Math.floor(Number(section.value) / 1000) + const cap = +max + if (total > cap) { + this.logger("frequency cap exceeded", { total, cap, amt, paymentCount: payments.length }) + return { ok: false, failure: ndebitFailure(5, { max: cap }) } } } - return 0 - } catch (err: any) { - return 0 + return { ok: true } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/services/main/debitTypes.ts b/src/services/main/debitTypes.ts index f6d9466e3..95d9f0c0d 100644 --- a/src/services/main/debitTypes.ts +++ b/src/services/main/debitTypes.ts @@ -97,6 +97,26 @@ export const nofferErrors = { export const k1AlreadyProcessedReason = "K1 already processed" +export type NdebitFailureWithExtras = NdebitFailure & { + range?: { min: number, max: number } +} + +export const ndebitFailure = (code: number, opts: { error?: string, max?: number } = {}): NdebitFailureWithExtras => { + const failure: NdebitFailureWithExtras = { + res: 'GFY', + code, + error: opts.error ?? nofferErrors[code as keyof typeof nofferErrors] ?? nofferErrors[1], + } + if (code === 5 && opts.max !== undefined) { + failure.range = { min: 1, max: opts.max } + } + return failure +} + +export type ValidateAccessRulesResult = + | { ok: true } + | { ok: false, failure: NdebitFailureWithExtras } + export type AuthRequiredRes = { status: 'authRequired', liveDebitReq: Types.LiveDebitRequest, app: Application, appUser: ApplicationUser } export type HandleNdebitRes = { status: 'fail', debitRes: NdebitFailure } | { status: 'invoicePaid', app: Application, appUser: ApplicationUser, debitRes: NdebitSuccess } diff --git a/src/services/main/index.ts b/src/services/main/index.ts index b18e0015e..ac621e620 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -31,6 +31,7 @@ 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' +import { PaymentSideEffects } from "./paymentSideEffects.js" type UserOperationsSub = { id: string newIncomingInvoice: (operation: Types.UserOperation) => void @@ -61,6 +62,7 @@ export default class { rugPullTracker: RugPullTracker unlocker: Unlocker notificationsManager: NotificationsManager + paymentSideEffects: PaymentSideEffects nostrProcessPing: (() => Promise) | null = null nostrReset: (settings: NostrSettings) => void = () => { getLogger({})("nostr reset not initialized yet") } constructor(settings: SettingsManager, storage: Storage, adminManager: AdminManager, utils: Utils, unlocker: Unlocker) { @@ -80,12 +82,12 @@ export default class { this.lnd = new LND(lndGetSettings, this.liquidityProvider, () => this.unlocker.Unlock(), this.utils, this.addressPaidCb, this.invoicePaidCb, this.newBlockCb, this.htlcCb, this.channelEventCb) 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.newBlockCb) + this.paymentSideEffects = new PaymentSideEffects(this.storage, this.utils, this.notificationsManager) + this.paymentManager = new PaymentManager(this.storage, this.metricsManager, this.lnd, adminManager.swaps, this.settings, this.liquidityManager, this.paymentSideEffects, 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) - this.debitManager = new DebitManager(this.storage, this.lnd, this.applicationManager) + this.debitManager = new DebitManager(this.storage, this.lnd, this.applicationManager, this.paymentManager) this.offerManager = new OfferManager(this.storage, this.settings, this.lnd, this.applicationManager, this.productManager, this.liquidityManager) this.managementManager = new ManagementManager(this.storage, this.settings) this.notificationsManager = new NotificationsManager(this.settings) @@ -187,257 +189,120 @@ export default class { const { linkedApplication, user, address, paid_amount: amount, service_fees: serviceFee, serial_id: serialId, chain_fees } = c.tx; const operationId = `${Types.UserOperationType.OUTGOING_TX}-${serialId}` const op = { amount, paidAtUnix: Date.now() / 1000, inbound: false, type: Types.UserOperationType.OUTGOING_TX, identifier: address, operationId, network_fee: chain_fees, service_fee: serviceFee, confirmed: true, tx_hash: c.tx.tx_hash, internal: c.tx.internal } - this.sendOperationToNostr(linkedApplication!, user.user_id, op) + try { + await this.paymentSideEffects.sendOperationToNostr(linkedApplication!, user.user_id, op) + } catch (err: any) { + log(ERROR, "error sending operation to nostr for outgoing tx", err.message || "") + } } else { - this.storage.StartTransaction(async tx => { - const { user_address: userAddress, paid_amount: amount, service_fee: serviceFee, serial_id: serialId, tx_hash } = c.tx - if (!userAddress.linkedApplication) { - log(ERROR, "an address was paid, that has no linked application") - return - } + const { user_address: userAddress, paid_amount: amount, service_fee: serviceFee, serial_id: serialId, tx_hash } = c.tx + if (!userAddress.linkedApplication) { + log(ERROR, "an address was paid, that has no linked application") + return + } + await this.storage.StartTransaction(async tx => { const affected = await this.storage.paymentStorage.UpdateAddressReceivingTransaction(serialId, { confs: c.confs }, tx) if (!affected) { throw new Error("unable to flag chain transaction as paid") } const addressData = `${userAddress.address}:${tx_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 }) + 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 }) await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, amount - serviceFee, addressData, tx) if (serviceFee > 0) { - await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, serviceFee, 'fees', tx) + await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication!.owner.user_id, serviceFee, 'fees', tx) } - const operationId = `${Types.UserOperationType.INCOMING_TX}-${serialId}` - const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: serviceFee, confirmed: true, tx_hash: c.tx.tx_hash, internal: c.tx.internal } - this.sendOperationToNostr(userAddress.linkedApplication!, userAddress.user.user_id, op) }) + const operationId = `${Types.UserOperationType.INCOMING_TX}-${serialId}` + const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: serviceFee, confirmed: true, tx_hash: c.tx.tx_hash, internal: c.tx.internal } + try { + await this.paymentSideEffects.sendOperationToNostr(userAddress.linkedApplication!, userAddress.user.user_id, op) + } catch (err: any) { + log(ERROR, "error sending operation to nostr for incoming tx", err.message || "") + } } })) } - addressPaidCb: AddressPaidCb = (txOutput, address, amount, used, broadcastHeight) => { - return this.storage.StartTransaction(async tx => { - getLogger({})("addressPaidCb called", JSON.stringify({ txOutput, address, amount, used, broadcastHeight })) - // On-chain payments not supported when bypass is enabled - if (this.liquidityProvider.getSettings().useOnlyLiquidityProvider) { - getLogger({})("addressPaidCb called but USE_ONLY_LIQUIDITY_PROVIDER is enabled, ignoring") - return - } - const { blockHeight } = await this.lnd.GetInfo() - const userAddress = await this.storage.paymentStorage.GetAddressOwner(address, tx) - if (!userAddress) { - const isChange = await this.lnd.IsChangeAddress(address) - if (isChange) { - return - } - await this.metricsManager.AddRootAddressPaid(address, txOutput, amount) - return - } - const internal = used === 'internal' - let log = getLogger({}) - if (!userAddress.linkedApplication) { - log(ERROR, "an address was paid, that has no linked application") + addressPaidCb: AddressPaidCb = async (txOutput, address, amount, used, broadcastHeight) => { + getLogger({})("addressPaidCb called", JSON.stringify({ txOutput, address, amount, used, broadcastHeight })) + if (this.liquidityProvider.getSettings().useOnlyLiquidityProvider) { + getLogger({})("addressPaidCb called but USE_ONLY_LIQUIDITY_PROVIDER is enabled, ignoring") + return + } + const { blockHeight } = await this.lnd.GetInfo() + const userAddress = await this.storage.paymentStorage.GetAddressOwner(address) + if (!userAddress) { + const isChange = await this.lnd.IsChangeAddress(address) + if (isChange) { return } - log = getLogger({ appName: userAddress.linkedApplication.name }) - const isManagedUser = userAddress.user.user_id !== userAddress.linkedApplication.owner.user_id - const fee = this.paymentManager.getReceiveServiceFee(Types.UserOperationType.INCOMING_TX, amount, isManagedUser) - try { - // This call will fail if the transaction is already registered - const txBroadcastHeight = broadcastHeight ? broadcastHeight : blockHeight - const addedTx = await this.storage.paymentStorage.AddAddressReceivingTransaction(userAddress, txOutput.hash, txOutput.index, amount, fee, internal, txBroadcastHeight, tx) + await this.metricsManager.AddRootAddressPaid(address, txOutput, amount) + return + } + if (!userAddress.linkedApplication) { + getLogger({})(ERROR, "an address was paid, that has no linked application") + return + } + const internal = used === 'internal' + const log = getLogger({ appName: userAddress.linkedApplication.name }) + const isManagedUser = userAddress.user.user_id !== userAddress.linkedApplication.owner.user_id + const fee = this.paymentManager.getReceiveServiceFee(Types.UserOperationType.INCOMING_TX, amount, isManagedUser) + const txBroadcastHeight = broadcastHeight ? broadcastHeight : blockHeight + let addedTx + try { + addedTx = await this.storage.StartTransaction(async tx => { + const txRecord = 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 }) - await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, addedTx.paid_amount - fee, addressData, tx) + 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 }) + await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, txRecord.paid_amount - fee, addressData, tx) if (fee > 0) { - await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, fee, 'fees', tx) + await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication!.owner.user_id, fee, 'fees', tx) } - - } - const operationId = `${Types.UserOperationType.INCOMING_TX}-${addedTx.serial_id}` - const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: fee, confirmed: internal, tx_hash: txOutput.hash, internal: false } - this.sendOperationToNostr(userAddress.linkedApplication, userAddress.user.user_id, op) - this.utils.stateBundler.AddTxPoint('addressWasPaid', amount, { used, from: 'system', timeDiscount: true }, userAddress.linkedApplication.app_id) - } catch (err: any) { - this.utils.stateBundler.AddTxPointFailed('addressWasPaid', amount, { used, from: 'system' }, userAddress.linkedApplication.app_id) - log(ERROR, "cannot process address paid transaction, already registered") - } - }) - } - - invoicePaidCb: InvoicePaidCb = (paymentRequest, amount, used) => { - return this.storage.StartTransaction(async tx => { - let log = getLogger({}) - const userInvoice = await this.storage.paymentStorage.GetInvoiceOwner(paymentRequest, tx) - if (!userInvoice) { - await this.metricsManager.AddRootInvoicePaid(paymentRequest, amount) - return - } - const internal = used === 'internal' - if (userInvoice.paid_at_unix > 0 && internal) { log("cannot pay internally, invoice already paid"); return } - if (userInvoice.paid_at_unix > 0 && !internal && userInvoice.paidByLnd) { log("invoice already paid by lnd"); return } - if (!userInvoice.linkedApplication) { - log(ERROR, "an invoice was paid, that has no linked application") - return - } - log = getLogger({ appName: userInvoice.linkedApplication.name }) - const isManagedUser = userInvoice.user.user_id !== userInvoice.linkedApplication.owner.user_id - const fee = this.paymentManager.getReceiveServiceFee(Types.UserOperationType.INCOMING_INVOICE, amount, isManagedUser) - try { - const paidInvoice = await this.storage.paymentStorage.FlagInvoiceAsPaid(userInvoice, amount, fee, internal, tx) - this.storage.eventsLog.LogEvent({ type: 'invoice_paid', userId: paidInvoice.user.user_id, appId: paidInvoice.linkedApplication!.app_id, appUserId: "", balance: paidInvoice.user.balance_sats, data: paymentRequest, amount }) - await this.storage.userStorage.IncrementUserBalance(paidInvoice.user.user_id, amount - fee, paidInvoice.invoice, tx) - if (fee > 0) { - await this.storage.userStorage.IncrementUserBalance(paidInvoice.linkedApplication!.owner.user_id, fee, 'fees', tx) - } - this.triggerPaidCallback(log, paidInvoice.callbackUrl, { invoice: paymentRequest, amount, payerData: paidInvoice.payer_data, token: paidInvoice.bearer_token, rejectUnauthorized: paidInvoice.rejectUnauthorized }) - const operationId = `${Types.UserOperationType.INCOMING_INVOICE}-${paidInvoice.serial_id}` - const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_INVOICE, identifier: paidInvoice.invoice, operationId, network_fee: 0, service_fee: fee, confirmed: true, tx_hash: "", internal } - this.sendOperationToNostr(paidInvoice.linkedApplication!, paidInvoice.user.user_id, op) - try { - await this.createZapReceipt(log, paidInvoice) - } catch (err: any) { - log(ERROR, "cannot create zap receipt", err.message || "") - } - // Send CLINK receipt if this invoice was from a noffer request - try { - if (paidInvoice.clink_requester_pub && paidInvoice.clink_requester_event_id) { - await this.createClinkReceipt(log, paidInvoice) - } - } 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 }, paidInvoice.linkedApplication!.app_id) - } catch (err: any) { - this.utils.stateBundler.AddTxPointFailed('invoiceWasPaid', amount, { used, from: 'system' }, userInvoice.linkedApplication.app_id) - log(ERROR, "cannot process paid invoice", err.message || "") - } - }) - } - - async triggerPaidCallback(log: PubLogger, url: string, - { invoice, amount, payerData, token, rejectUnauthorized }: - { - invoice: string, - amount: number, - payerData?: Record, - token?: string, - rejectUnauthorized?: boolean - } - ) { - if (!url) { - return - } - let finalUrl = ""; - const payerDataToExpand = { - amount, - invoice, - ...(payerData !== undefined ? payerData : {}) - }; - try { - const parsed = parse(url); - finalUrl = parsed.expand(payerDataToExpand) + return txRecord + }) } catch (err: any) { - log(ERROR, "error expanding callback url template for invoice", err?.message || ""); - return; - } - const symbol = finalUrl.includes('?') ? "&" : "?" - finalUrl = finalUrl + symbol + "ok=true" - - const headers = { - ...(token ? { Authorization: `Bearer ${token}` } : {}) + this.utils.stateBundler.AddTxPointFailed('addressWasPaid', amount, { used, from: 'system' }, userAddress.linkedApplication.app_id) + log(ERROR, "cannot process address paid transaction, already registered") + return } + const operationId = `${Types.UserOperationType.INCOMING_TX}-${addedTx.serial_id}` + const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: fee, confirmed: internal, tx_hash: txOutput.hash, internal: false } try { - const hostname = new URL(finalUrl).hostname - log("sending paid callback to", hostname) - await safeOutboundFetch(finalUrl, { headers, rejectUnauthorized }) + await this.paymentSideEffects.sendOperationToNostr(userAddress.linkedApplication, userAddress.user.user_id, op) } catch (err: any) { - log(ERROR, "error sending paid callback for invoice", err.message || "") + log(ERROR, "error sending operation to nostr for incoming tx", err.message || "") } + this.utils.stateBundler.AddTxPoint('addressWasPaid', amount, { used, from: 'system', timeDiscount: true }, userAddress.linkedApplication.app_id) } - async sendOperationToNostr(app: Application, userId: string, op: Types.UserOperation) { - const user = await this.storage.applicationStorage.GetAppUserFromUser(app, userId) - if (!user || !user.nostr_public_key) { - getLogger({ appName: app.name })("cannot notify user, not a nostr user") + invoicePaidCb: InvoicePaidCb = async (paymentRequest, amount, used) => { + const log = getLogger({}) + const userInvoice = await this.storage.paymentStorage.GetInvoiceOwner(paymentRequest) + if (!userInvoice) { + /* If its not a user invoice, it is a root invoice, we dont need to credit it, or trigger any side effects, just add the metric */ + await this.metricsManager.AddRootInvoicePaid(paymentRequest, amount) + log("invoice tracked successfully to root", used, amount, paymentRequest) return } - const balance = user.user.balance_sats - const message: Types.LiveUserOperation & { requestId: string, status: 'OK' } = - { 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.getOperationMessage(op)) - } - - 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" - } - } - } + let paidInvoice: UserReceivingInvoice + try { + paidInvoice = await this.storage.StartTransaction(async tx => { + const internal = used === 'internal' + return this.paymentManager.CreditIncomingInvoice(paymentRequest, amount, internal, tx) + }) - 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) { + } catch (err: any) { + this.utils.stateBundler.AddTxPointFailed('invoiceWasPaid', amount, { used, from: 'system' }, userInvoice.linkedApplication!.app_id) + log(ERROR, "cannot process paid invoice", err.message || "") return } - const tokens = devices.map(d => d.firebase_messaging_token) - const ck = nip44.getConversationKey(Buffer.from(app.nostr_private_key, 'hex'), appUser.nostr_public_key) - - let payloadToEncrypt: Types.PushNotificationPayload; - if (op.inbound) { - payloadToEncrypt = { - data: { - type: Types.PushNotificationPayload_data_type.RECEIVED_OPERATION, - received_operation: op - } - } - } else { - payloadToEncrypt = { - data: { - type: Types.PushNotificationPayload_data_type.SENT_OPERATION, - sent_operation: op - } - } - } - const j = JSON.stringify(payloadToEncrypt) - const encrypted = nip44.encrypt(j, ck) - - const envelope: Types.PushNotificationEnvelope = { - topic_id: appUser.topic_id, - app_npub_hex: app.nostr_public_key, - encrypted_payload: encrypted - } - const notification: ShockPushNotification = { - message: JSON.stringify(envelope), - body, - title - } - await this.notificationsManager.SendNotification(notification, tokens, { - pubkey: app.nostr_public_key!, - privateKey: app.nostr_private_key! - }) + this.liquidityManager.afterInInvoicePaid() + this.utils.stateBundler.AddTxPoint('invoiceWasPaid', amount, { used, from: 'system', timeDiscount: true }, paidInvoice.linkedApplication!.app_id) + log("invoice credited successfully to user", paidInvoice.user.user_id, used, amount, paymentRequest) + await this.paymentSideEffects.TriggerPaidInvoiceSideEffects(log, paidInvoice) } async UpdateBeacon(app: Application, content: Types.BeaconData) { @@ -456,62 +321,7 @@ export default class { this.utils.nostrSender.Send({ type: 'app', appId: app.app_id }, { type: 'event', event }) } - async createZapReceipt(log: PubLogger, invoice: UserReceivingInvoice) { - const zapInfo = invoice.zap_info - if (!zapInfo || !invoice.linkedApplication || !invoice.linkedApplication.nostr_public_key) { - return - } - const tags = [["p", zapInfo.pub]] - if (zapInfo.senderPub) { - tags.push(["P", zapInfo.senderPub]) - } - if (zapInfo.eventId) { - tags.push(["e", zapInfo.eventId]) - } - if (zapInfo.eventCoordinate) { - tags.push(["a", zapInfo.eventCoordinate]) - } - if (zapInfo.eventKind) { - tags.push(["k", zapInfo.eventKind]) - } - tags.push(["bolt11", invoice.invoice], ["description", zapInfo.description]) - const event: UnsignedEvent = { - content: "", - created_at: invoice.paid_at_unix, - kind: 9735, - pubkey: invoice.linkedApplication.nostr_public_key, - tags, - } - log({ unsigned: event }) - this.utils.nostrSender.Send({ type: 'app', appId: invoice.linkedApplication.app_id }, { type: 'event', event }, zapInfo.relays || undefined) - } - async createClinkReceipt(log: PubLogger, invoice: UserReceivingInvoice) { - if (!invoice.clink_requester_pub || !invoice.clink_requester_event_id || !invoice.linkedApplication) { - return - } - log("📤 [CLINK RECEIPT] Sending payment receipt", { - toPub: invoice.clink_requester_pub, - eventId: invoice.clink_requester_event_id - }) - // 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", invoice.clink_requester_pub], - ["e", invoice.clink_requester_event_id], - ["clink_version", "1"] - ], - } - this.utils.nostrSender.Send( - { type: 'app', appId: invoice.linkedApplication.app_id }, - { type: 'event', event, encrypt: { toPub: invoice.clink_requester_pub } } - ) - } async ResetNostr() { const apps = await this.storage.applicationStorage.GetApplications() diff --git a/src/services/main/managementManager.ts b/src/services/main/managementManager.ts index c70b9a2c2..018891fb4 100644 --- a/src/services/main/managementManager.ts +++ b/src/services/main/managementManager.ts @@ -11,6 +11,7 @@ import { UnsignedEvent } from "nostr-tools"; import { getLogger, PubLogger, ERROR } from "../helpers/logger.js"; import SettingsManager from "./settingsManager.js"; import { assertCallbackUrlAllowed, SafeOutboundFetchError } from "../helpers/safeOutboundFetch.js"; +import { assertValidOfferPriceSats } from "../helpers/offerValidation.js"; type Result = { state: 'success', result: T } | { state: 'error', err: NmanageFailure } | { state: 'authRequired' } export class ManagementManager { @@ -199,8 +200,15 @@ export class ManagementManager { if (!fields.label || typeof fields.label !== 'string') { return { state: 'error', err: { res: 'GFY', code: 5, error: 'Invalid Field/Value', field: 'label' } } } - if (fields.price_sats && typeof fields.price_sats !== 'number') { - return { state: 'error', err: { res: 'GFY', code: 5, error: 'Invalid Field/Value', field: 'price_sats' } } + if (fields.price_sats !== undefined && fields.price_sats !== null) { + if (typeof fields.price_sats !== 'number') { + return { state: 'error', err: { res: 'GFY', code: 5, error: 'Invalid Field/Value', field: 'price_sats' } } + } + try { + assertValidOfferPriceSats(fields.price_sats) + } catch { + return { state: 'error', err: { res: 'GFY', code: 5, error: 'Invalid Field/Value', field: 'price_sats' } } + } } if (fields.callback_url && typeof fields.callback_url !== 'string') { return { state: 'error', err: { res: 'GFY', code: 5, error: 'Invalid Field/Value', field: 'callback_url' } } @@ -251,7 +259,7 @@ export class ManagementManager { return { state: 'authRequired' } } - if (grant.expires_at_unix > 0 && grant.expires_at_unix < Date.now()) { + if (grant.expires_at_unix > 0 && grant.expires_at_unix < Math.floor(Date.now() / 1000)) { this.logger(ERROR, "Grant expired", appUserId, requestorPub) return { state: 'authRequired' } } diff --git a/src/services/main/offerManager.ts b/src/services/main/offerManager.ts index e42a5ac3e..eebcec018 100644 --- a/src/services/main/offerManager.ts +++ b/src/services/main/offerManager.ts @@ -11,6 +11,7 @@ import { LiquidityManager } from "./liquidityManager.js" import { NofferData, OfferPriceType, nofferEncode } from '@shocknet/clink-sdk'; import SettingsManager from "./settingsManager.js"; import { assertCallbackUrlAllowed } from "../helpers/safeOutboundFetch.js"; +import { assertValidOfferPriceSats } from "../helpers/offerValidation.js"; type NofferInvoiceFailure = { success: false, code: number, max: number, error?: string, payer_data?: string[] } type NofferInvoiceResult = { success: true, invoice: string } | NofferInvoiceFailure @@ -52,6 +53,7 @@ export class OfferManager { } async AddUserOffer(ctx: Types.UserContext, req: Types.OfferCreateRequest): Promise { + assertValidOfferPriceSats(req.price_sats) assertCallbackUrlAllowed(req.callback_url) const newOffer = await this.storage.offerStorage.AddUserOffer(ctx.app_user_id, { payer_data: req.payer_data, @@ -72,6 +74,7 @@ export class OfferManager { } async UpdateUserOffer(ctx: Types.UserContext, req: Types.OfferUpdateRequest) { + assertValidOfferPriceSats(req.price_sats) assertCallbackUrlAllowed(req.callback_url) await this.storage.offerStorage.UpdateUserOffer(ctx.app_user_id, req.offer_id, { payer_data: req.payer_data, @@ -249,6 +252,8 @@ export class OfferManager { return { success: false, code: 5, max: remote } } amt = amount + } else if (userOffer.price_sats < 0) { + return { success: false, code: 5, max: remote } } const dataCheck = this.ValidateExpectedData(userOffer, offerReq.payer_data) if (!dataCheck.passed) { diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 74e1ac320..2712c2fbb 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -23,6 +23,7 @@ import { Transaction, OutputDetail } from '../../../proto/lnd/lightning.js' import { LndAddress } from '../lnd/lnd.js' import Metrics from '../metrics/index.js' import { TxPointSettings } from '../storage/tlv/stateBundler.js' +import { PaymentSideEffects } from './paymentSideEffects.js' interface UserOperationInfo { serial_id: number paid_amount: number @@ -72,7 +73,6 @@ export default class { settings: SettingsManager lnd: LND addressPaidCb: AddressPaidCb - invoicePaidCb: InvoicePaidCb newBlockCb: NewBlockCb log = getLogger({ component: "PaymentManager" }) watchDog: Watchdog @@ -81,17 +81,18 @@ 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, newBlockCb: NewBlockCb) { + paymentSideEffects: PaymentSideEffects + constructor(storage: Storage, metrics: Metrics, lnd: LND, swaps: Swaps, settings: SettingsManager, liquidityManager: LiquidityManager, sideEffects: PaymentSideEffects, utils: Utils, addressPaidCb: AddressPaidCb, newBlockCb: NewBlockCb) { this.storage = storage this.metrics = metrics this.settings = settings this.lnd = lnd this.liquidityManager = liquidityManager this.utils = utils + this.paymentSideEffects = sideEffects this.watchDog = new Watchdog(settings, this.liquidityManager, this.lnd, this.storage, this.utils, this.liquidityManager.rugPullTracker) this.swaps = swaps this.addressPaidCb = addressPaidCb - this.invoicePaidCb = invoicePaidCb this.newBlockCb = newBlockCb this.invoiceLock = new InvoiceLock() } @@ -585,21 +586,59 @@ export default class { } const { payAmount, serviceFee } = amounts const totalAmountToDecrement = payAmount + serviceFee - await this.storage.userStorage.DecrementUserBalance(userId, totalAmountToDecrement, internalInvoice.invoice) + let newPayment: UserInvoicePayment + let paidInvoice: UserReceivingInvoice try { - await this.invoicePaidCb(internalInvoice.invoice, payAmount, 'internal') - const newPayment = await this.storage.paymentStorage.AddInternalPayment(userId, internalInvoice.invoice, payAmount, serviceFee, linkedApplication, debitNpub) - this.utils.stateBundler.AddTxPoint('paidAnInvoice', totalAmountToDecrement, { used: 'internal', from: 'user' }, linkedApplication.app_id) - return { preimage: "", amtPaid: payAmount, networkFee: 0, serialId: newPayment.serial_id } + ({ newPayment, paidInvoice } = await this.storage.StartTransaction(async tx => { + await this.storage.userStorage.DecrementUserBalance(userId, totalAmountToDecrement, internalInvoice.invoice, tx) + const internal = true + const credited = await this.CreditIncomingInvoice(internalInvoice.invoice, payAmount, internal, tx) + const payment = await this.storage.paymentStorage.AddInternalPayment(userId, internalInvoice.invoice, payAmount, serviceFee, linkedApplication, debitNpub, tx) + return { newPayment: payment, paidInvoice: credited } + })) } catch (err) { - await this.storage.userStorage.IncrementUserBalance(userId, totalAmountToDecrement, "internal_payment_refund:" + internalInvoice.invoice) this.utils.stateBundler.AddTxPointFailed('paidAnInvoice', totalAmountToDecrement, { used: 'internal', from: 'user' }, linkedApplication.app_id) throw err } + + this.liquidityManager.afterInInvoicePaid() + this.utils.stateBundler.AddTxPoint('invoiceWasPaid', payAmount, { used: 'internal', from: 'system', timeDiscount: true }, paidInvoice.linkedApplication!.app_id) + this.log("invoice credited successfully to user", paidInvoice.user.user_id, 'internal', payAmount, internalInvoice.invoice) + await this.paymentSideEffects.TriggerPaidInvoiceSideEffects(this.log, paidInvoice) + this.utils.stateBundler.AddTxPoint('paidAnInvoice', totalAmountToDecrement, { used: 'internal', from: 'user' }, linkedApplication.app_id) + return { preimage: "", amtPaid: payAmount, networkFee: 0, serialId: newPayment.serial_id } } + async CreditIncomingInvoice(invoice: string, amount: number, internal: boolean, txId: string) { + if (amount < 0) { + throw new Error("amount cannot be negative") + } + const userInvoice = await this.storage.paymentStorage.GetInvoiceOwner(invoice, txId) + if (!userInvoice) { + throw new Error("invoice not found") + } + if (userInvoice.paid_at_unix > 0 && internal) { + throw new Error("cannot pay internally, invoice already paid") + } + if (userInvoice.paid_at_unix > 0 && !internal && userInvoice.paidByLnd) { + throw new Error("invoice already paid by lnd") + } + if (!userInvoice.linkedApplication) { + throw new Error("an invoice was paid, that has no linked application") + } + const isManagedUser = userInvoice.user.user_id !== userInvoice.linkedApplication.owner.user_id + const fee = this.getReceiveServiceFee(Types.UserOperationType.INCOMING_INVOICE, amount, isManagedUser) + const paidInvoice = await this.storage.paymentStorage.FlagInvoiceAsPaid(userInvoice, amount, fee, internal, txId) + this.storage.eventsLog.LogEvent({ type: 'invoice_paid', userId: paidInvoice.user.user_id, appId: paidInvoice.linkedApplication!.app_id, appUserId: "", balance: paidInvoice.user.balance_sats, data: invoice, amount }) + await this.storage.userStorage.IncrementUserBalance(paidInvoice.user.user_id, amount - fee, paidInvoice.invoice, txId) + if (fee > 0) { + await this.storage.userStorage.IncrementUserBalance(paidInvoice.linkedApplication!.owner.user_id, fee, 'fees', txId) + } + return paidInvoice + } + async GetTransactionSwapQuotes(ctx: Types.UserContext, req: Types.TransactionSwapRequest): Promise { const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const quotes = await this.swaps.GetTxSwapQuotes(ctx.app_user_id, req.transaction_amount_sats, decodedAmt => { @@ -754,6 +793,21 @@ export default class { } } + async GetMaxSendable() { + const { remote } = await this.lnd.ChannelBalance() + const ready = this.liquidityManager.liquidityProvider.IsReady() + if (remote === 0 && ready) { + return 10_000_000 * 1000 + } + return remote * 1000 + } + + async GetMinAndMaxSendable() { + const minSendable = 10000 + const maxSendable = await this.GetMaxSendable() + return { minSendable, maxSendable } + } + async GetLnurlPayInfoFromUser(userId: string, linkedApplication: Application, opts: { baseUrl?: string, metadata?: string } = {}): Promise { if (this.isDefaultServiceUrl()) { throw new Error("Lnurl not enabled. Make sure to set SERVICE_URL env variable") @@ -761,16 +815,12 @@ export default class { const { baseUrl, metadata } = opts const payK1 = await this.storage.paymentStorage.AddUserEphemeralKey(userId, 'pay', linkedApplication) const url = baseUrl ? baseUrl : `${this.settings.getSettings().serviceSettings.serviceUrl}/api/guest/lnurl_pay/handle` - const { remote } = await this.lnd.ChannelBalance() - let maxSendable = remote * 1000 - if (remote === 0 && (await this.liquidityManager.liquidityProvider.IsReady())) { - maxSendable = 10_000_000 * 1000 - } + const { maxSendable, minSendable } = await this.GetMinAndMaxSendable() return { tag: 'payRequest', callback: `${url}?k1=${payK1.key}`, maxSendable: maxSendable, - minSendable: 10000, + minSendable: minSendable, metadata: metadata ? metadata : defaultLnurlPayMetadata(this.settings.getSettings().serviceSettings.lnurlMetaText), allowsNostr: !!linkedApplication.nostr_public_key, nostrPubkey: linkedApplication.nostr_public_key || "" @@ -864,6 +914,10 @@ export default class { if (isNaN(amountMillis)) { throw new Error("invalid amount in lnurl pay to handle") } + const { maxSendable, minSendable } = await this.GetMinAndMaxSendable() + if (amountMillis < minSendable || amountMillis > maxSendable) { + throw new Error("amount out of range") + } let zapInfo: ZapInfo | undefined if (ctx.nostr) { zapInfo = this.validateZapEvent(ctx.nostr, amountMillis) diff --git a/src/services/main/paymentSideEffects.ts b/src/services/main/paymentSideEffects.ts new file mode 100644 index 000000000..17c8e4555 --- /dev/null +++ b/src/services/main/paymentSideEffects.ts @@ -0,0 +1,239 @@ +import { ERROR, getLogger, PubLogger } from "../helpers/logger.js" +import { parse } from "uri-template" +import { safeOutboundFetch } from "../helpers/safeOutboundFetch.js" +import { Application } from "../storage/entity/Application.js" +import * as Types from '../../../proto/autogenerated/ts/types.js' +import { Utils } from "../helpers/utilsWrapper.js" +import { ShockPushNotification } from "../ShockPush/index.js" +import { nip44, UnsignedEvent } from "nostr-tools" +import { NotificationsManager } from "./notificationsManager.js" +import { ApplicationUser } from "../storage/entity/ApplicationUser.js" +import { UserReceivingInvoice } from "../storage/entity/UserReceivingInvoice.js" +import Storage from "../storage/index.js" + +export class PaymentSideEffects { + storage: Storage + utils: Utils + notificationsManager: NotificationsManager + constructor(storage: Storage, utils: Utils, notificationsManager: NotificationsManager) { + this.storage = storage + this.utils = utils + this.notificationsManager = notificationsManager + } + + async TriggerPaidInvoiceSideEffects(log: PubLogger, paidInvoice: UserReceivingInvoice) { + try { + this.triggerPaidCallback(log, paidInvoice.callbackUrl, { + invoice: paidInvoice.invoice, amount: paidInvoice.paid_amount, payerData: paidInvoice.payer_data, + token: paidInvoice.bearer_token, rejectUnauthorized: paidInvoice.rejectUnauthorized + }) + } catch (err: any) { + log(ERROR, "error triggering paid callback for invoice: " + err.message || "") + } + + const operationId = `${Types.UserOperationType.INCOMING_INVOICE}-${paidInvoice.serial_id}` + const op = { + amount: paidInvoice.paid_amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_INVOICE, + identifier: paidInvoice.invoice, operationId, network_fee: 0, service_fee: paidInvoice.service_fee, confirmed: true, tx_hash: "", + internal: paidInvoice.internal + } + try { + await this.sendOperationToNostr(paidInvoice.linkedApplication!, paidInvoice.user.user_id, op) + } catch (err: any) { + log(ERROR, "error sending operation to nostr for invoice: " + err.message || "") + } + + try { + await this.createZapReceipt(log, paidInvoice) + } catch (err: any) { + log(ERROR, "error creating zap receipt for invoice: " + err.message || "") + } + + try { + await this.createClinkReceipt(log, paidInvoice) + } catch (err: any) { + log(ERROR, "error creating clink receipt for invoice: " + err.message || "") + } + } + + + async triggerPaidCallback(log: PubLogger, url: string, + { invoice, amount, payerData, token, rejectUnauthorized }: + { + invoice: string, + amount: number, + payerData?: Record, + token?: string, + rejectUnauthorized?: boolean + } + ) { + if (!url) { + return + } + let finalUrl = ""; + const payerDataToExpand = { + amount, + invoice, + ...(payerData !== undefined ? payerData : {}) + }; + try { + const parsed = parse(url); + finalUrl = parsed.expand(payerDataToExpand) + } catch (err: any) { + log(ERROR, "error expanding callback url template for invoice", err?.message || ""); + return; + } + const symbol = finalUrl.includes('?') ? "&" : "?" + finalUrl = finalUrl + symbol + "ok=true" + + const headers = { + ...(token ? { Authorization: `Bearer ${token}` } : {}) + } + try { + const hostname = new URL(finalUrl).hostname + log("sending paid callback to", hostname) + await safeOutboundFetch(finalUrl, { headers, rejectUnauthorized }) + } catch (err: any) { + log(ERROR, "error sending paid callback for invoice", err.message || "") + } + } + + async sendOperationToNostr(app: Application, userId: string, op: Types.UserOperation) { + const user = await this.storage.applicationStorage.GetAppUserFromUser(app, userId) + if (!user || !user.nostr_public_key) { + throw new Error("cannot notify user, not a nostr user") + } + const balance = user.user.balance_sats + const message: Types.LiveUserOperation & { requestId: string, status: 'OK' } = + { 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 }) + await this.SendEncryptedNotification(app, user, op, this.getOperationMessage(op)) + } + + 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 + } + + const tokens = devices.map(d => d.firebase_messaging_token) + const ck = nip44.getConversationKey(Buffer.from(app.nostr_private_key, 'hex'), appUser.nostr_public_key) + + let payloadToEncrypt: Types.PushNotificationPayload; + if (op.inbound) { + payloadToEncrypt = { + data: { + type: Types.PushNotificationPayload_data_type.RECEIVED_OPERATION, + received_operation: op + } + } + } else { + payloadToEncrypt = { + data: { + type: Types.PushNotificationPayload_data_type.SENT_OPERATION, + sent_operation: op + } + } + } + const j = JSON.stringify(payloadToEncrypt) + const encrypted = nip44.encrypt(j, ck) + + const envelope: Types.PushNotificationEnvelope = { + topic_id: appUser.topic_id, + app_npub_hex: app.nostr_public_key, + encrypted_payload: encrypted + } + const notification: ShockPushNotification = { + message: JSON.stringify(envelope), + body, + title + } + await this.notificationsManager.SendNotification(notification, tokens, { + pubkey: app.nostr_public_key!, + privateKey: app.nostr_private_key! + }) + } + + async createZapReceipt(log: PubLogger, invoice: UserReceivingInvoice) { + const zapInfo = invoice.zap_info + if (!zapInfo || !invoice.linkedApplication || !invoice.linkedApplication.nostr_public_key) { + return + } + const tags = [["p", zapInfo.pub]] + if (zapInfo.senderPub) { + tags.push(["P", zapInfo.senderPub]) + } + if (zapInfo.eventId) { + tags.push(["e", zapInfo.eventId]) + } + if (zapInfo.eventCoordinate) { + tags.push(["a", zapInfo.eventCoordinate]) + } + if (zapInfo.eventKind) { + tags.push(["k", zapInfo.eventKind]) + } + tags.push(["bolt11", invoice.invoice], ["description", zapInfo.description]) + const event: UnsignedEvent = { + content: "", + created_at: invoice.paid_at_unix, + kind: 9735, + pubkey: invoice.linkedApplication.nostr_public_key, + tags, + } + log({ unsigned: event }) + this.utils.nostrSender.Send({ type: 'app', appId: invoice.linkedApplication.app_id }, { type: 'event', event }, zapInfo.relays || undefined) + } + + async createClinkReceipt(log: PubLogger, invoice: UserReceivingInvoice) { + if (!invoice.clink_requester_pub || !invoice.clink_requester_event_id || !invoice.linkedApplication) { + return + } + log("📤 [CLINK RECEIPT] Sending payment receipt", { + toPub: invoice.clink_requester_pub, + eventId: invoice.clink_requester_event_id + }) + // 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", invoice.clink_requester_pub], + ["e", invoice.clink_requester_event_id], + ["clink_version", "1"] + ], + } + this.utils.nostrSender.Send( + { type: 'app', appId: invoice.linkedApplication.app_id }, + { type: 'event', event, encrypt: { toPub: invoice.clink_requester_pub } } + ) + } +} \ No newline at end of file diff --git a/src/services/serverMethods/index.ts b/src/services/serverMethods/index.ts index e82c170bf..16fae73e6 100644 --- a/src/services/serverMethods/index.ts +++ b/src/services/serverMethods/index.ts @@ -463,6 +463,7 @@ export default (mainHandler: Main): Types.ServerMethods => { AddUserOffer: async ({ ctx, req }) => { const err = Types.OfferCreateRequestValidate(req, { label_CustomCheck: label => label !== '', + price_sats_CustomCheck: price => price >= 0, }) if (err != null) throw new Error(err.message) return mainHandler.offerManager.AddUserOffer(ctx, req) @@ -477,6 +478,7 @@ export default (mainHandler: Main): Types.ServerMethods => { UpdateUserOffer: async ({ ctx, req }) => { const err = Types.OfferUpdateRequestValidate(req, { offer_id_CustomCheck: id => id !== '', + price_sats_CustomCheck: price => price >= 0, }) if (err != null) throw new Error(err.message) return mainHandler.offerManager.UpdateUserOffer(ctx, req) diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 046f29b45..2211a72a2 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -68,6 +68,9 @@ export default class { } async FlagInvoiceAsPaid(invoice: UserReceivingInvoice, amount: number, serviceFee: number, internal: boolean, txId: string): Promise { + if (amount < 0) { + throw new Error("amount cannot be negative") + } const i: Partial = { paid_at_unix: Math.floor(Date.now() / 1000), paid_amount: amount, service_fee: serviceFee, internal } if (!internal) { i.paidByLnd = true @@ -216,10 +219,10 @@ export default class { return this.dbs.Update('UserInvoicePayment', invoicePaymentSerialId, up, txId) } - async AddInternalPayment(userId: string, invoice: string, amount: number, serviceFees: number, linkedApplication: Application, debitNpub?: string): Promise { - const user = await this.userStorage.GetUser(userId) + async AddInternalPayment(userId: string, invoice: string, amount: number, serviceFees: number, linkedApplication: Application, debitNpub?: string, txId?: string): Promise { + const user = await this.userStorage.GetUser(userId, txId) return this.dbs.CreateAndSave('UserInvoicePayment', { - user: await this.userStorage.GetUser(userId), + user, paid_amount: amount, invoice, routing_fees: 0, @@ -228,7 +231,7 @@ export default class { internal: true, linkedApplication, debit_to_pub: debitNpub - }) + }, txId) } GetUserInvoicePayments(userId: string, fromIndex: number, take = 50, txId?: string): Promise { diff --git a/src/services/storage/userStorage.ts b/src/services/storage/userStorage.ts index 30aab7609..632faa783 100644 --- a/src/services/storage/userStorage.ts +++ b/src/services/storage/userStorage.ts @@ -80,7 +80,7 @@ export default class { throw new Error("increment cannot be negative") } const user = await this.GetUser(userId, txId) - const affected = await this.dbs.Increment('User', { user_id: userId }, "balance_sats", increment, txId) + const affected = await this.dbs.Increment('User', { user_id: userId, balance_sats: user.balance_sats }, "balance_sats", increment, txId) if (!affected) { getLogger({ userId: userId, component: "balanceUpdates" })("user unaffected by increment") throw new Error("unaffected balance increment") @@ -108,7 +108,7 @@ export default class { getLogger({ userId: userId, component: "balanceUpdates" })("not enough balance to decrement") throw new Error("not enough balance to decrement") } - const affected = await this.dbs.Decrement('User', { user_id: userId }, "balance_sats", decrement, txId) + const affected = await this.dbs.Decrement('User', { user_id: userId, balance_sats: user.balance_sats }, "balance_sats", decrement, txId) if (!affected) { getLogger({ userId: userId, component: "balanceUpdates" })("user unaffected by decrement") throw new Error("unaffected balance decrement") diff --git a/src/tests/debitManager.spec.ts b/src/tests/debitManager.spec.ts index f413dfdf4..3c1c3f361 100644 --- a/src/tests/debitManager.spec.ts +++ b/src/tests/debitManager.spec.ts @@ -47,6 +47,17 @@ const authorizeDebit = async (T: TestBase, npub: string, rules: Types.DebitRule[ }) } +const frequencyCapRules = (maxSats: number): Types.DebitRule[] => [{ + rule: { + type: Types.DebitRule_rule_type.FREQUENCY_RULE, + frequency_rule: { + number_of_intervals: 1, + interval: Types.IntervalType.DAY, + amount: maxSats, + }, + }, +}] + const testGetDebitAuthorizationsEmpty = async (T: TestBase) => { T.d("starting testGetDebitAuthorizationsEmpty") const unknownPub = requestorPub(0) @@ -262,6 +273,46 @@ const testPayNdebitInvoiceFrequencyWithoutAmount = async (T: TestBase) => { T.d("payNdebitInvoice fails when frequency is provided without amount") } +const testPayNdebitInvoiceFrequencyCapExceeded = async (T: TestBase) => { + T.d("starting testPayNdebitInvoiceFrequencyCapExceeded") + const npub = requestorPub(12) + await authorizeDebit(T, npub, frequencyCapRules(1000)) + const invoice1 = await T.externalAccessToOtherLnd.NewInvoice(400, "debit cap 1", defaultInvoiceExpiry, { from: 'system', useProvider: false }) + const first = await T.main.debitManager.payNdebitInvoice( + mockNostrEvent(T, npub, "cap-first"), + { + pointer: T.user2.appUserIdentifier, + bolt11: invoice1.payRequest, + amount_sats: 400, + }, + ) + T.expect(first.status).to.equal("invoicePaid") + const invoice2 = await T.externalAccessToOtherLnd.NewInvoice(500, "debit cap 2", defaultInvoiceExpiry, { from: 'system', useProvider: false }) + const second = await T.main.debitManager.payNdebitInvoice( + mockNostrEvent(T, npub, "cap-second"), + { + pointer: T.user2.appUserIdentifier, + bolt11: invoice2.payRequest, + amount_sats: 500, + }, + ) + T.expect(second.status).to.equal("invoicePaid") + const invoice3 = await T.externalAccessToOtherLnd.NewInvoice(200, "debit cap 3", defaultInvoiceExpiry, { from: 'system', useProvider: false }) + const third = await T.main.debitManager.payNdebitInvoice( + mockNostrEvent(T, npub, "cap-third"), + { + pointer: T.user2.appUserIdentifier, + bolt11: invoice3.payRequest, + amount_sats: 200, + }, + ) + expectDebitFail(T, third, 5, nofferErrors[5]) + if (third.status === "fail") { + T.expect((third.debitRes as { range?: { max: number } }).range?.max).to.equal(1000) + } + T.d("payNdebitInvoice returns code 5 when frequency cap is exceeded") +} + const testPayNdebitInvoiceInsufficientBalance = async (T: TestBase) => { T.d("starting testPayNdebitInvoiceInsufficientBalance") const npub = requestorPub(10) @@ -308,6 +359,7 @@ export default async (T: TestBase) => { await testPayNdebitInvoiceAmountMismatch(T) await testPayNdebitInvoiceAmountWithoutBolt11(T) await testPayNdebitInvoiceFrequencyWithoutAmount(T) + await testPayNdebitInvoiceFrequencyCapExceeded(T) await testPayNdebitInvoiceInsufficientBalance(T) await testRespondToDebitInvalidTypeThrows(T) await runSanityCheck(T) diff --git a/src/tests/externalPayment.spec.ts b/src/tests/externalPayment.spec.ts index 7a03e8281..4b7fd21e3 100644 --- a/src/tests/externalPayment.spec.ts +++ b/src/tests/externalPayment.spec.ts @@ -1,5 +1,5 @@ import { defaultInvoiceExpiry } from '../services/storage/paymentStorage.js' -import { Describe, expect, expectThrowsAsync, runSanityCheck, safelySetUserBalance, TestBase } from './testBase.js' +import { Describe, expect, expectThrowsAsync, mineAndWaitForUserBalance, runSanityCheck, safelySetUserBalance, TestBase } from './testBase.js' import * as Types from '../../proto/autogenerated/ts/types.js' export const ignore = false export const dev = false @@ -57,19 +57,13 @@ const testSuccesfulReceivedExternalChainPayment = async (T: TestBase) => { const payment = await T.externalAccessToOtherLnd.PayAddress(user2Address.address, 1000, 3, "test", { from: 'system', useProvider: false }) expect(payment.txid).to.not.be.undefined T.d("paid 1000 sats to user2's external chain address") - await T.chainTools.mine(1) - await new Promise(resolve => setTimeout(resolve, 100)) + await mineAndWaitForUserBalance(T, T.user2.userId, 1000) T.d("mined 1 blocks to confirm the payment") - const u2 = await T.main.storage.userStorage.GetUser(T.user2.userId) - expect(u2.balance_sats).to.be.equal(1000) T.d("user2 balance is now 1000") const payment2 = await T.externalAccessToOtherLnd.PayAddress(user2Address.address, 1000, 3, "test", { from: 'system', useProvider: false }) expect(payment2.txid).to.not.be.undefined T.d("paid 1000 sats to user2's external chain address again") - await T.chainTools.mine(1) - await new Promise(resolve => setTimeout(resolve, 100)) + await mineAndWaitForUserBalance(T, T.user2.userId, 2000) T.d("mined 1 blocks to confirm the payment") - const u2_2 = await T.main.storage.userStorage.GetUser(T.user2.userId) - expect(u2_2.balance_sats).to.be.equal(2000) T.d("user2 balance is now 2000") } diff --git a/src/tests/nofferPayerData.spec.ts b/src/tests/nofferPayerData.spec.ts index 002e21d72..5fc6c4aaf 100644 --- a/src/tests/nofferPayerData.spec.ts +++ b/src/tests/nofferPayerData.spec.ts @@ -1,7 +1,7 @@ import { NofferData } from "@shocknet/clink-sdk" import * as Types from "../../proto/autogenerated/ts/types.js" import { NostrEvent } from "../services/nostr/nostrPool.js" -import { runSanityCheck, TestBase } from "./testBase.js" +import { runSanityCheck, expectThrowsAsync, TestBase } from "./testBase.js" export const ignore = false export const dev = false @@ -377,6 +377,43 @@ const testDefaultOfferPayerDataNotCleared = async (T: TestBase) => { T.d("default offer payer_data survives failed and successful invoice requests") } +const testNegativeOfferPriceRejectedOnCreate = async (T: TestBase) => { + T.d("starting testNegativeOfferPriceRejectedOnCreate") + const ctx = userContext(T, T.user2) + await expectThrowsAsync( + T.main.offerManager.AddUserOffer(ctx, offerCreate({ label: "negative price offer", price_sats: -1000 })), + "price_sats cannot be negative", + ) + T.d("AddUserOffer rejected negative price_sats") +} + +const testNegativeOfferPriceRejectedOnUpdate = async (T: TestBase) => { + T.d("starting testNegativeOfferPriceRejectedOnUpdate") + const ctx = userContext(T, T.user2) + const { offer_id } = await T.main.offerManager.AddUserOffer(ctx, offerCreate({ label: "offer to corrupt" })) + await expectThrowsAsync( + T.main.offerManager.UpdateUserOffer(ctx, { + offer_id, + ...offerCreate({ label: "offer to corrupt", price_sats: -500 }), + }), + "price_sats cannot be negative", + ) + T.d("UpdateUserOffer rejected negative price_sats") +} + +const testStaleNegativeOfferPriceRejectedOnInvoice = async (T: TestBase) => { + T.d("starting testStaleNegativeOfferPriceRejectedOnInvoice") + const ctx = userContext(T, T.user2) + const { offer_id } = await T.main.offerManager.AddUserOffer(ctx, offerCreate({ label: "stale negative offer" })) + await T.main.storage.offerStorage.UpdateUserOffer(ctx.app_user_id, offer_id, { price_sats: -1000 }) + const result = await T.main.offerManager.getNofferInvoice({ offer: offer_id }, T.app.appId) + T.expect(result.success).to.equal(false) + if (!result.success) { + T.expect(result.code).to.equal(5) + } + T.d("getNofferInvoice rejected stale negative fixed offer price") +} + export default async (T: TestBase) => { await testFixedPriceOfferWithPayerData(T) await testSpontaneousOfferWithPayerData(T) @@ -394,5 +431,8 @@ export default async (T: TestBase) => { await testUpdateOfferPayerData(T) await testDeleteUserOffer(T) await testGetUserOfferInvoicesStoresPayerData(T) + await testNegativeOfferPriceRejectedOnCreate(T) + await testNegativeOfferPriceRejectedOnUpdate(T) + await testStaleNegativeOfferPriceRejectedOnInvoice(T) await runSanityCheck(T) } diff --git a/src/tests/safeOutboundFetch.spec.ts b/src/tests/safeOutboundFetch.spec.ts index f20686abd..739a847e6 100644 --- a/src/tests/safeOutboundFetch.spec.ts +++ b/src/tests/safeOutboundFetch.spec.ts @@ -1,6 +1,7 @@ import { isMetadataIPv4, isMetadataIPv6, + isBlockedCallbackIp, validateCallbackUrlForEgress, assertCallbackUrlAllowed, SafeOutboundFetchError, @@ -23,6 +24,17 @@ export default async (T: TestBase) => { T.expect(isMetadataIPv6("::ffff:169.254.169.254")).to.equal(true) T.expect(isMetadataIPv6("::1")).to.equal(false) + T.expect(isBlockedCallbackIp("127.0.0.1")).to.equal(false) + T.expect(isBlockedCallbackIp("::1")).to.equal(false) + T.expect(isBlockedCallbackIp("10.0.0.1")).to.equal(true) + T.expect(isBlockedCallbackIp("172.16.0.1")).to.equal(true) + T.expect(isBlockedCallbackIp("192.168.1.10")).to.equal(true) + T.expect(isBlockedCallbackIp("100.64.0.1")).to.equal(true) + T.expect(isBlockedCallbackIp("169.254.169.254")).to.equal(true) + T.expect(isBlockedCallbackIp("8.8.8.8")).to.equal(false) + T.expect(isBlockedCallbackIp("::ffff:192.168.1.1")).to.equal(true) + T.expect(isBlockedCallbackIp("::ffff:127.0.0.1")).to.equal(false) + const assertBlocked = (url: string) => { try { validateCallbackUrlForEgress(new URL(url)) @@ -36,10 +48,12 @@ export default async (T: TestBase) => { assertBlocked("http://metadata.google.internal/computeMetadata/v1/") assertBlocked("http://user:pass@example.com/callback") assertBlocked("file:///etc/passwd") + assertBlocked("http://10.0.0.1/callback") + assertBlocked("http://172.16.0.1/callback") + assertBlocked("http://192.168.1.10/webhook") validateCallbackUrlForEgress(new URL("http://127.0.0.1/callback")) validateCallbackUrlForEgress(new URL("http://localhost/callback")) - validateCallbackUrlForEgress(new URL("http://192.168.1.10/webhook")) validateCallbackUrlForEgress(new URL("https://example.com/callback?invoice={invoice}")) assertCallbackUrlAllowed("http://127.0.0.1/callback?invoice={invoice}") diff --git a/src/tests/testBase.ts b/src/tests/testBase.ts index ef06aeda8..ff7fa130e 100644 --- a/src/tests/testBase.ts +++ b/src/tests/testBase.ts @@ -132,6 +132,27 @@ export const runSanityCheck = async (T: TestBase) => { await sanityChecker.VerifyEventsLog() } +export const waitForUserBalance = async (T: TestBase, userId: string, expectedBalance: number, timeoutMs = 30000) => { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const { blockHeight } = await T.main.lnd.GetInfo() + await T.main.newBlockCb(blockHeight, true) + const user = await T.main.storage.userStorage.GetUser(userId) + if (user.balance_sats === expectedBalance) { + return user + } + await new Promise(resolve => setTimeout(resolve, 200)) + } + const user = await T.main.storage.userStorage.GetUser(userId) + expect(user.balance_sats).to.equal(expectedBalance) + return user +} + +export const mineAndWaitForUserBalance = async (T: TestBase, userId: string, expectedBalance: number, blocks = 1) => { + await T.chainTools.mine(blocks) + return waitForUserBalance(T, userId, expectedBalance) +} + export const expectThrowsAsync = async (promise: Promise, errorMessage?: string) => { let error: Error | null = null try {