diff --git a/api/paidAction/boost.js b/api/paidAction/boost.js index 05d8001743..e833df8a84 100644 --- a/api/paidAction/boost.js +++ b/api/paidAction/boost.js @@ -34,12 +34,12 @@ export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, c } export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) const [{ id, path }] = await tx.$queryRaw` SELECT "Item".id, ltree2text(path) as path FROM "Item" JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" - WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` + WHERE "ItemAct"."invoiceId" = ${invoiceId}::INTEGER` + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) return { id, sats: msatsToSats(cost), act: 'BOOST', path } } diff --git a/api/paidAction/downZap.js b/api/paidAction/downZap.js index 3807462ef8..0fb04b113a 100644 --- a/api/paidAction/downZap.js +++ b/api/paidAction/downZap.js @@ -35,12 +35,12 @@ export async function perform ({ invoiceId, sats, id: itemId }, { me, cost, tx } } export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) const [{ id, path }] = await tx.$queryRaw` SELECT "Item".id, ltree2text(path) as path FROM "Item" JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" - WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` + WHERE "ItemAct"."invoiceId" = ${invoiceId}::INTEGER` + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) return { id, sats: msatsToSats(cost), act: 'DONT_LIKE_THIS', path } } diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 81779883b8..a973b58cfc 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -1,7 +1,5 @@ -import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service' -import { datePivot } from '@/lib/time' -import { PAID_ACTION_PAYMENT_METHODS, PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants' -import { createHmac } from '../resolvers/wallet' +import { PAID_ACTION_TERMINAL_STATES, USER_ID, MAX_PENDING_PAID_ACTIONS_PER_USER, PAID_ACTION_PAYMENT_METHODS, INVOICE_EXPIRE_SECS } from '@/lib/constants' +import { parsePaymentRequest, createInvoice as lndCreateInvoice, createHodlInvoice as lndCreateHodlInvoice } from 'ln-service' import { Prisma } from '@prisma/client' import * as ITEM_CREATE from './itemCreate' import * as ITEM_UPDATE from './itemUpdate' @@ -15,8 +13,14 @@ import * as TERRITORY_UNARCHIVE from './territoryUnarchive' import * as DONATE from './donate' import * as BOOST from './boost' import * as BUY_CREDITS from './buyCredits' -import wrapInvoice from 'wallets/wrap' -import { createInvoice as createUserInvoice } from 'wallets/server' +import * as TRANSFER from './transfer' + +import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate' +import { createHmac } from '@/lib/crypto' +import { finalizeHodlInvoice } from 'worker/wallet' +import { datePivot } from '@/lib/time' + +import { SN_WALLET, createInvoice as createUserInvoice, createHodlInvoice as createUserHodlInvoice } from 'wallets/server' export const paidActions = { ITEM_CREATE, @@ -30,75 +34,159 @@ export const paidActions = { TERRITORY_BILLING, TERRITORY_UNARCHIVE, DONATE, + TRANSFER, BUY_CREDITS } +async function checkUser (userId, { models, tx, me }, minSats) { + // count pending invoices and bail if we're over the limit + const pendingInvoices = await (tx ?? models).invoice.count({ + where: { + userId, + actionState: { + // not in a terminal state. Note: null isn't counted by prisma + notIn: PAID_ACTION_TERMINAL_STATES + } + } + }) + + console.log('pending paid actions', pendingInvoices) + if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) { + throw new Error('You have too many pending paid actions, cancel some or wait for them to expire') + } + + if (minSats !== undefined && toPositiveBigInt(minSats) > (me?.msats ?? 0n)) { + throw new Error('You do not have enough sats to perform this action') + } +} + export default async function performPaidAction (actionType, args, context) { try { - const { me, models, forceFeeCredits } = context - const paidAction = paidActions[actionType] - console.group('performPaidAction', actionType, args) - + const paidAction = paidActions[actionType] if (!paidAction) { throw new Error(`Invalid action type ${actionType}`) } - if (!me && !paidAction.anonable) { - throw new Error('You must be logged in to perform this action') - } + context.me = context.me ? await context.models.user.findUnique({ where: { id: context.me.id } }) : undefined + context.actionAttempt = context.actionAttempt ?? 0 + context.forceInternal = context.forceInternal ?? false + context.prioritizeInternal = context.prioritizeInternal ?? false + + context.cost = await paidAction.getCost(args, context) ?? 0n + if (context.cost < 0n) throw new Error('Cost cannot be negative') - context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined - context.cost = await paidAction.getCost(args, context) + context.sybilFeePercent = paidAction.getSybilFeePercent ? await paidAction.getSybilFeePercent(args, context) : undefined + if (context.sybilFeePercent !== undefined && context.sybilFeePercent < 0n) throw new Error('Sybil fee percent cannot be negative') - // special case for zero cost actions - if (context.cost === 0n) { + context.description = context.me?.hideInvoiceDesc ? undefined : await paidAction.describe(args, context) + context.descriptionHash = paidAction.describeHash ? await paidAction.describeHash(args, context) : undefined + context.supportedPaymentMethods = paidAction.paymentMethods ?? (paidAction.getPaymentMethods ? await paidAction.getPaymentMethods(args, context) : undefined) + + const { + me, + actionAttempt, + forceInternal, + cost, + sybilFeePercent, + description, + descriptionHash, + prioritizeInternal, + lnd + } = context + + let supportedPaymentMethods = context.supportedPaymentMethods + + if (cost === 0n) { console.log('performing zero cost action') - return await performNoInvoiceAction(actionType, args, context, 'ZERO_COST') + // this cannot fail, ever + return await performInternalAction(cost, actionType, paidAction, PAID_ACTION_PAYMENT_METHODS.ZERO_COST, args, context) } - for (const paymentMethod of paidAction.paymentMethods) { - console.log(`performing payment method ${paymentMethod}`) - - if (forceFeeCredits && - paymentMethod !== PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT && - paymentMethod !== PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) { - throw new Error('forceFeeCredits is set, but user does not have enough fee credits or reward sats') + if (forceInternal) { + // forced internal transaction, so we keep only the payment methods that qualify as such + if (!me) { + throw new Error('user must be logged in to use internal payments') } + const forcedPaymentMethods = [] + if (supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT)) { + forcedPaymentMethods.push(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) + } + if (supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.REWARD_SATS)) { + forcedPaymentMethods.push(PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) + } + if (forcedPaymentMethods.length === 0) { + throw new Error('action does not support internal payments') + } + supportedPaymentMethods = forcedPaymentMethods + } else if (prioritizeInternal) { + // move internal payments to the top (fee first) + // without removing the other payment methods + const priority = { + [PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT]: -2, + [PAID_ACTION_PAYMENT_METHODS.REWARD_SATS]: -1 + } + supportedPaymentMethods = supportedPaymentMethods.sort((a, b) => { + return (priority[a] || 0) - (priority[b] || 0) + }) + console.log('Prioritize internal payment methods', supportedPaymentMethods) + } - // payment methods that anonymous users can use - if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) { - try { - return await performP2PAction(actionType, args, context) - } catch (e) { - if (!(e instanceof NonInvoiceablePeerError)) { - console.error(`${paymentMethod} action failed`, e) - throw e + for (const paymentMethod of supportedPaymentMethods) { + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) { + if (me && (me.mcredits ?? 0n) >= cost) { + try { + return await performInternalAction(cost, actionType, paidAction, paymentMethod, args, context) + } catch (e) { + console.error('payment method failed', paymentMethod, e) } } - } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) { - return await performPessimisticAction(actionType, args, context) - } - - // additionalpayment methods that logged in users can use - if (me) { - if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT || - paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) { + } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) { + if (me && (me.msats ?? 0n) >= cost) { try { - return await performNoInvoiceAction(actionType, args, context, paymentMethod) + return await performInternalAction(cost, actionType, paidAction, paymentMethod, args, context) } catch (e) { - // if we fail with fee credits or reward sats, but not because of insufficient funds, bail - console.error(`${paymentMethod} action failed`, e) - if (!e.message.includes('\\"users\\" violates check constraint \\"mcredits_positive\\"') && - !e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { - throw e - } + console.error('payment method failed', paymentMethod, e) } - } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) { - return await performOptimisticAction(actionType, args, context) } + } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) { + try { + const optimistic = + paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC || + // if action supports optimism and its a logged p2p action, we can try to be p2p and optimistic + (me && paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && + supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) + + const receiverUserId = paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P ? await paidAction.invoiceablePeer?.(args, context) : undefined + if (!receiverUserId) throw new Error("this action doesn't support p2p payment method") + const invoiceData = await (optimistic ? createUserInvoice : createUserHodlInvoice)(receiverUserId, { + msats: cost, + description, + descriptionHash, + sybilFeePercent, + attempt: actionAttempt + }, context) + if (optimistic) return await performOptimisticAction(invoiceData, actionType, paidAction, args, context) + return await performPessimisticAction(invoiceData, actionType, paidAction, args, context) + } catch (e) { + console.error('payment method failed', paymentMethod, e) + } + } else { + // anon users are never optimistic + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC && !me) continue + const optimistic = paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC + const invoiceData = await createSNInvoice({ + description, + descriptionHash, + cost, + isHodl: !optimistic + }, + { lnd }) + if (optimistic) return await performOptimisticAction(invoiceData, actionType, paidAction, args, context) + return await performPessimisticAction(invoiceData, actionType, paidAction, args, context) } } + throw new Error('No payment method available') } catch (e) { console.error('performPaidAction failed', e) throw e @@ -107,283 +195,304 @@ export default async function performPaidAction (actionType, args, context) { } } -async function performNoInvoiceAction (actionType, args, context, paymentMethod) { - const { me, models, cost } = context - const action = paidActions[actionType] +async function performAction (invoiceEntry, paidAction, args, context) { + const { retryForInvoice } = context + if (retryForInvoice && paidAction.retry) { + return await paidAction.retry({ invoiceId: retryForInvoice.id, newInvoiceId: invoiceEntry?.id }, context) + } else { + return await paidAction.perform?.({ invoiceId: invoiceEntry?.id, ...args }, context) + } +} - const result = await models.$transaction(async tx => { +async function performInternalAction (cost, actionType, paidAction, paymentMethod, args, context) { + const { me, models, retryForInvoice } = context + const run = async tx => { context.tx = tx + await checkUser(me?.id ?? USER_ID.anon, context, cost) + + try { + if (paymentMethod !== PAID_ACTION_PAYMENT_METHODS.ZERO_COST) { + if (cost <= 0n) throw new Error('invalid cost') + console.log('enough fee credits available, performing internal action') + if ((paymentMethod === 'REWARD_SATS' || paymentMethod === 'FEE_CREDIT')) { + await tx.user.update({ + where: { + id: me?.id ?? USER_ID.anon + }, + data: paymentMethod === 'REWARD_SATS' + ? { msats: { decrement: cost } } + : { mcredits: { decrement: cost } } + }) + console.log('Paid action with ' + paymentMethod) + } else throw new Error('Invalid payment method ' + paymentMethod) + } else { + console.log('No cost paid action') + } - if (paymentMethod === 'REWARD_SATS' || paymentMethod === 'FEE_CREDIT') { - await tx.user.update({ - where: { - id: me?.id ?? USER_ID.anon - }, - data: paymentMethod === 'REWARD_SATS' - ? { msats: { decrement: cost } } - : { mcredits: { decrement: cost } } - }) - } - - const result = await action.perform(args, context) - await action.onPaid?.(result, context) + const result = await performAction(null, paidAction, args, context) + await paidAction.onPaid?.({ ...result, invoice: null }, context) - return { - result, - paymentMethod + return { + result, + paymentMethod, + retriableWithWallet: false + } + } catch (e) { + console.error('internal paid action failed', e) + // if it is retrying a fee credit action, we mark it as failed so it can be retried imeediately + if (retryForInvoice) { + await tx.invoice.update({ + where: { + id: retryForInvoice.id + }, + data: { + actionState: 'FAILED' + } + }) + } + throw e } - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + } + const result = context.tx + ? await run(context.tx) + : await models.$transaction(run, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) // run non critical side effects in the background // after the transaction has been committed - action.nonCriticalSideEffects?.(result.result, context).catch(console.error) + paidAction.nonCriticalSideEffects?.(result.result, context).catch(console.error) return result } -async function performOptimisticAction (actionType, args, context) { - const { models } = context - const action = paidActions[actionType] - - context.optimistic = true - const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(actionType, args, context) - - return await models.$transaction(async tx => { +async function performOptimisticAction (invoiceData, actionType, paidAction, args, context) { + const { models, actionId, me } = context + const performInvoicedAction = async tx => { context.tx = tx - - const invoice = await createDbInvoice(actionType, args, context, invoiceArgs) - + context.optimistic = true + await checkUser(me?.id ?? USER_ID.anon, context) + + const invoiceEntry = await createDbInvoice(invoiceData, { + actionType, + actionId, + optimistic: true, + args + }, context) return { - invoice, - result: await action.perform?.({ invoiceId: invoice.id, ...args }, context), - paymentMethod: 'OPTIMISTIC' + invoice: invoiceEntry, + result: await performAction(invoiceEntry, paidAction, args, context), + paymentMethod: 'OPTIMISTIC', + retriableWithWallet: invoiceData.retriable } - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) -} - -async function performPessimisticAction (actionType, args, context) { - const action = paidActions[actionType] - - if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC)) { - throw new Error(`This action ${actionType} does not support pessimistic invoicing`) - } - - // just create the invoice and complete action when it's paid - const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(actionType, args, context) - return { - invoice: await createDbInvoice(actionType, args, context, invoiceArgs), - paymentMethod: 'PESSIMISTIC' } + if (context.tx) return await performInvoicedAction(context.tx) + return await models.$transaction(performInvoicedAction, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) } -async function performP2PAction (actionType, args, context) { - const { me } = context - const invoiceArgs = await createWrappedInvoice(actionType, args, context) - context.invoiceArgs = invoiceArgs - - return me - ? await performOptimisticAction(actionType, args, context) - : await performPessimisticAction(actionType, args, context) -} - -export async function retryPaidAction (actionType, args, context) { - const { models, me } = context - const { invoice: failedInvoice } = args - - console.log('retryPaidAction', actionType, args) - - const action = paidActions[actionType] - if (!action) { - throw new Error(`retryPaidAction - invalid action type ${actionType}`) - } - - if (!me) { - throw new Error(`retryPaidAction - must be logged in ${actionType}`) - } - - if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) { - throw new Error(`retryPaidAction - action does not support optimism ${actionType}`) - } - - if (!action.retry) { - throw new Error(`retryPaidAction - action does not support retrying ${actionType}`) - } - - if (!failedInvoice) { - throw new Error(`retryPaidAction - missing invoice ${actionType}`) - } - - context.optimistic = true - context.me = await models.user.findUnique({ where: { id: me.id } }) - - const { msatsRequested, actionId } = failedInvoice - context.cost = BigInt(msatsRequested) - context.actionId = actionId - const invoiceArgs = await createSNInvoice(actionType, args, context) - - return await models.$transaction(async tx => { +async function performPessimisticAction (invoiceData, actionType, paidAction, args, context) { + const { models, actionId, me } = context + const performInvoicedAction = async tx => { context.tx = tx - - // update the old invoice to RETRYING, so that it's not confused with FAILED - await tx.invoice.update({ - where: { - id: failedInvoice.id, - actionState: 'FAILED' - }, - data: { - actionState: 'RETRYING' - } - }) - - // create a new invoice - const invoice = await createDbInvoice(actionType, args, context, invoiceArgs) - + context.optimistic = false + await checkUser(me?.id ?? USER_ID.anon, context) + + const invoiceEntry = await createDbInvoice(invoiceData, { + actionType, + actionId, + optimistic: false, + args + }, context) return { - result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context), - invoice, - paymentMethod: 'OPTIMISTIC' + invoice: invoiceEntry, + paymentMethod: 'PESSIMISTIC', + retriableWithWallet: invoiceData.retriable } - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + } + if (context.tx) return await performInvoicedAction(context.tx) + return await models.$transaction(performInvoicedAction, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) } -const INVOICE_EXPIRE_SECS = 600 -const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 +export async function retryPaidAction ({ invoiceId, forceInternal, resetAttempts, prioritizeInternal }, context) { + const { models, lnd, boss, me } = context -export async function assertBelowMaxPendingInvoices (context) { - const { models, me } = context - const pendingInvoices = await models.invoice.count({ - where: { - userId: me?.id ?? USER_ID.anon, - actionState: { - notIn: PAID_ACTION_TERMINAL_STATES - } + return await models.$transaction(async tx => { + context.tx = tx + const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id ?? USER_ID.anon } }) + if (!failedInvoice) { + throw new Error('Invoice not found') } - }) - if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) { - throw new Error('You have too many pending paid actions, cancel some or wait for them to expire') - } -} + if ( + failedInvoice.actionState !== 'FAILED' && + failedInvoice.actionState !== 'PENDING' && + failedInvoice.actionState !== 'PENDING_HELD' + ) { + throw new Error(`Invoice is not in a retriable state: ${failedInvoice.actionState}`) + } -export class NonInvoiceablePeerError extends Error { - constructor () { - super('non invoiceable peer') - this.name = 'NonInvoiceablePeerError' - } -} + // cancel the previous invoice + await finalizeHodlInvoice({ data: { hash: failedInvoice.hash }, lnd, models, boss }) -export async function createWrappedInvoice (actionType, args, context) { - // if the action has an invoiceable peer, we'll create a peer invoice - // wrap it, and return the wrapped invoice - const { cost, models, lnd, me } = context - const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context) - if (!userId) { - throw new NonInvoiceablePeerError() - } + const actionType = failedInvoice.actionType - await assertBelowMaxPendingInvoices(context) + console.log('retryPaidAction', actionType, failedInvoice) - const description = await paidActions[actionType].describe(args, context) - const { invoice: bolt11, wallet } = await createUserInvoice(userId, { - // this is the amount the stacker will receive, the other 3/10ths is the sybil fee - msats: cost * BigInt(7) / BigInt(10), - description, - expiry: INVOICE_EXPIRE_SECS - }, { models }) + const paidAction = paidActions[actionType] + if (!paidAction) { + throw new Error(`retryPaidAction - invalid action type ${actionType}`) + } - const { invoice: wrappedInvoice, maxFee } = await wrapInvoice( - bolt11, { msats: cost, description }, { me, lnd }) + if (!failedInvoice) { + throw new Error(`retryPaidAction - missing invoice ${actionType}`) + } - return { - bolt11, - wrappedBolt11: wrappedInvoice.request, - wallet, - maxFee - } -} + const { msatsRequested, actionId, actionArgs } = failedInvoice + context.cost = msatsRequested + context.actionId = actionId + context.retryForInvoice = failedInvoice + context.forceInternal = forceInternal + context.actionAttempt = failedInvoice.actionAttempt + 1 // next attempt + context.prioritizeInternal = prioritizeInternal -// we seperate the invoice creation into two functions because -// because if lnd is slow, it'll timeout the interactive tx -async function createSNInvoice (actionType, args, context) { - const { me, lnd, cost, optimistic } = context - const action = paidActions[actionType] - const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice + if (resetAttempts) { + await tx.invoice.update({ + where: { + id: failedInvoice.id + }, + data: { + actionAttempt: 0 + } + }) + } - if (cost < 1000n) { - // sanity check - throw new Error('The cost of the action must be at least 1 sat') - } + const supportRetrying = paidAction.retry + if (supportRetrying) { + // update the old invoice to RETRYING, so that it's not confused with FAILED + await tx.invoice.update({ + where: { + id: failedInvoice.id, + actionState: 'FAILED' + }, + data: { + actionState: 'RETRYING' + } + }) + } - const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) - const invoice = await createLNDInvoice({ - description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context), - lnd, - mtokens: String(cost), - expires_at: expiresAt - }) - return { bolt11: invoice.request, preimage: invoice.secret } + return await performPaidAction(actionType, actionArgs, context) + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) } -async function createDbInvoice (actionType, args, context, - { bolt11, wrappedBolt11, preimage, wallet, maxFee }) { - const { me, models, tx, cost, optimistic, actionId } = context +export async function createDbInvoice ( + invoiceData, + actionData, + { me, models, tx } +) { + const { invoice: servedBolt11, wallet, maxFee, preimage, innerInvoice: bolt11, isWrapped, isHodl } = invoiceData + const { actionId, actionType, optimistic: isOptimisticAction, args } = actionData + const db = tx ?? models - if (cost < 1000n) { - // sanity check - throw new Error('The cost of the action must be at least 1 sat') - } + const servedInvoice = await parsePaymentRequest({ request: servedBolt11 }) - const servedBolt11 = wrappedBolt11 ?? bolt11 - const servedInvoice = parsePaymentRequest({ request: servedBolt11 }) const expiresAt = new Date(servedInvoice.expires_at) - const invoiceData = { + // sanity checks + if (servedInvoice.mtokens < 1000n) { + throw new Error('The amount must be at least 1 sat') + } + if (!isWrapped) { + if (isHodl && isOptimisticAction) throw new Error('optimistic actions should not use hodl invoices') + if (!isOptimisticAction && !isHodl) throw new Error('pessimistic actions should use hodl invoices') + } else { + if (isWrapped && !isHodl) throw new Error('wrapped invoices should always be hodl') + } + + const invoiceEntryData = { hash: servedInvoice.id, msatsRequested: BigInt(servedInvoice.mtokens), preimage, bolt11: servedBolt11, userId: me?.id ?? USER_ID.anon, actionType, - actionState: wrappedBolt11 ? 'PENDING_HELD' : optimistic ? 'PENDING' : 'PENDING_HELD', - actionOptimistic: optimistic, + actionState: (isWrapped ? 'PENDING_HELD' : isOptimisticAction ? 'PENDING' : 'PENDING_HELD'), + actionOptimistic: isOptimisticAction, actionArgs: args, + actionAttempt: invoiceData.attempt, expiresAt, - actionId + actionId, + desc: invoiceData.description ?? servedInvoice.description } - let invoice - if (wrappedBolt11) { - invoice = (await db.invoiceForward.create({ + let invoiceEntry + if (isWrapped) { + const invoiceForward = await db.invoiceForward.create({ include: { invoice: true }, data: { bolt11, - maxFeeMsats: maxFee, + maxFeeMsats: toPositiveNumber(maxFee), invoice: { - create: invoiceData + create: invoiceEntryData }, wallet: { connect: { - id: wallet.id + id: wallet?.id } } } - })).invoice + }) + invoiceEntry = invoiceForward.invoice } else { - invoice = await db.invoice.create({ data: invoiceData }) + invoiceEntry = await db.invoice.create({ data: invoiceEntryData }) } // insert a job to check the invoice after it's set to expire await db.$executeRaw` INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority) VALUES ('checkInvoice', - jsonb_build_object('hash', ${invoice.hash}::TEXT), 21, true, + jsonb_build_object('hash', ${invoiceEntry.hash}::TEXT), 21, true, ${expiresAt}::TIMESTAMP WITH TIME ZONE, ${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)` // the HMAC is only returned during invoice creation // this makes sure that only the person who created this invoice // has access to the HMAC - invoice.hmac = createHmac(invoice.hash) + invoiceEntry.hmac = createHmac(invoiceEntry.hash) + + return invoiceEntry +} - return invoice +async function createSNInvoice ({ description, descriptionHash, cost, expiry = INVOICE_EXPIRE_SECS, isHodl }, { lnd }) { + // sanity check + cost = toPositiveBigInt(cost) + if (cost < 1000n) throw new Error('The cost of the action must be at least 1 sat') + const expiresAt = datePivot(new Date(), { seconds: expiry ?? INVOICE_EXPIRE_SECS }) + + let invoice + const mtokens = String(cost) + if (!isHodl) { + invoice = await lndCreateInvoice({ + description, + description_hash: descriptionHash, + lnd, + mtokens, + expires_at: expiresAt + }) + } else { + invoice = await lndCreateHodlInvoice({ + description, + description_hash: descriptionHash, + lnd, + mtokens, + expires_at: expiresAt + }) + } + return { + invoice: invoice.request, + preimage: invoice.secret, + wallet: SN_WALLET, + isWrapped: false, + isHodl, + msats: cost, + description, + retriable: false + } } diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js index 8f3a8c2d47..498f3e128d 100644 --- a/api/paidAction/itemCreate.js +++ b/api/paidAction/itemCreate.js @@ -149,13 +149,15 @@ export async function perform (args, context) { } export async function retry ({ invoiceId, newInvoiceId }, { tx }) { + const res = (await tx.$queryRaw` + SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" + FROM "Item" WHERE "invoiceId" = ${invoiceId}::INTEGER` + )[0] + res.invoiceId = newInvoiceId await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) await tx.item.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) await tx.upload.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - return (await tx.$queryRaw` - SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" - FROM "Item" WHERE "invoiceId" = ${newInvoiceId}::INTEGER` - )[0] + return res } export async function onPaid ({ invoice, id }, context) { diff --git a/api/paidAction/pollVote.js b/api/paidAction/pollVote.js index d2eb417855..a73e439190 100644 --- a/api/paidAction/pollVote.js +++ b/api/paidAction/pollVote.js @@ -42,11 +42,10 @@ export async function perform ({ invoiceId, id }, { me, cost, tx }) { } export async function retry ({ invoiceId, newInvoiceId }, { tx }) { + const { pollOptionId } = await tx.pollVote.findFirst({ where: { invoiceId } }) await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) await tx.pollBlindVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) await tx.pollVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - - const { pollOptionId } = await tx.pollVote.findFirst({ where: { invoiceId: newInvoiceId } }) return { id: pollOptionId } } diff --git a/api/paidAction/transfer.js b/api/paidAction/transfer.js new file mode 100644 index 0000000000..bea4972cf1 --- /dev/null +++ b/api/paidAction/transfer.js @@ -0,0 +1,57 @@ +import { satsToMsats } from '@/lib/format' +import { notifyDeposit } from '@/lib/webPush' +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' + +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.P2P +] + +export async function getCost ({ sats }) { + return satsToMsats(sats) +} + +export async function invoiceablePeer ({ targetUserId }, { models }) { + return targetUserId +} + +export async function perform ({ invoiceId, sats, description, descriptionHash, comment, targetUserId }, { me, tx }) { + const invoice = await tx.invoice.update({ + where: { id: invoiceId }, + data: { + comment + } + }) + await notifyDeposit(targetUserId, invoice) + return { sats, targetUserId } +} + +export async function describe ({ sats, description, descriptionHash }, context) { + return `SN: ${description ?? ''}` +} + +export async function describeHash ({ sats, description, descriptionHash }, context) { + return descriptionHash +} + +export async function getSybilFeePercent ({ sats, description, descriptionHash }, context) { + return 10n +} + +export async function onPaid ({ invoice }, { tx }) { + const isP2P = !!invoice.invoiceForward + if (isP2P) return + if (!invoice?.msatsReceived) throw new Error('Not paid?') + const targetUserId = invoice.actionArgs?.targetUserId + if (!targetUserId) throw new Error('No targetUserId') + + await tx.user.update({ + where: { id: targetUserId }, + data: { + msats: { + increment: invoice.msatsReceived + } + } + }) +} diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index 6b8e30364c..3ea87812b3 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -61,12 +61,12 @@ export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, c } export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) const [{ id, path }] = await tx.$queryRaw` SELECT "Item".id, ltree2text(path) as path FROM "Item" JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" - WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` + WHERE "ItemAct"."invoiceId" = ${invoiceId}::INTEGER` + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) return { id, sats: msatsToSats(cost), act: 'TIP', path } } diff --git a/api/resolvers/item.js b/api/resolvers/item.js index ae7b6dc586..548e45b711 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -20,8 +20,8 @@ import { uploadIdsFromText } from './upload' import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' import performPaidAction from '../paidAction' -import { GqlAuthenticationError, GqlInputError } from '@/lib/error' -import { verifyHmac } from './wallet' +import { GqlAuthorizationError, GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { verifyHmac } from '@/lib/crypto' function commentsOrderByClause (me, models, sort) { if (sort === 'recent') { @@ -1352,7 +1352,10 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. // anybody can edit with valid hash+hmac let hmacEdit = false if (old.invoice?.hash && hash && hmac) { - hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac) + if (!verifyHmac(hash, hmac)) { + throw new GqlAuthorizationError('bad hmac') + } + hmacEdit = old.invoice.hash === hash } // ownership permission check diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index a934f64f00..5c68ed5a10 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -228,6 +228,23 @@ export default { ORDER BY "sortTime" DESC LIMIT ${LIMIT})` ) + queries.push( + `(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", FLOOR("msatsReceived" / 1000) as "earnedSats", + 'InvoicePaid' AS type + FROM "Invoice" + WHERE "Invoice"."userId" = $1 + AND "confirmedAt" IS NOT NULL + AND "actionState" = 'PAID' + AND "actionType" = 'TRANSFER' + AND created_at < $2 + AND NOT EXISTS ( + SELECT 1 + FROM "InvoiceForward" + WHERE "InvoiceForward"."invoiceId" = "Invoice".id + ) + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) } if (meFull.noteWithdrawals) { diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js index 2332790384..410f6a336a 100644 --- a/api/resolvers/paidAction.js +++ b/api/resolvers/paidAction.js @@ -19,6 +19,8 @@ function paidActionType (actionType) { return 'DonatePaidAction' case 'POLL_VOTE': return 'PollVotePaidAction' + case 'TRANSFER': + return 'TransferPaidAction' default: throw new Error('Unknown action type') } @@ -41,33 +43,17 @@ export default { type: paidActionType(invoice.actionType), invoice, result: invoice.actionResult, - paymentMethod: invoice.actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC' + paymentMethod: invoice.actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC', + paid: invoice.actionState === 'PAID' } } }, Mutation: { - retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => { - if (!me) { - throw new Error('You must be logged in') - } - - const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } }) - if (!invoice) { - throw new Error('Invoice not found') - } - - if (invoice.actionState !== 'FAILED') { - if (invoice.actionState === 'PAID') { - throw new Error('Invoice is already paid') - } - throw new Error(`Invoice is not in failed state: ${invoice.actionState}`) - } - - const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd }) - + retryPaidAction: async (parent, { invoiceId, forceInternal, prioritizeInternal, resetAttempts }, { models, me, lnd }) => { + const result = await retryPaidAction({ invoiceId, forceInternal, prioritizeInternal, resetAttempts }, { models, me, lnd }) return { ...result, - type: paidActionType(invoice.actionType) + type: paidActionType(result.invoice.actionType) } } }, diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 188693aadd..0030296c0c 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -3,7 +3,7 @@ import { getInvoice as getInvoiceFromLnd, deletePayment, getPayment, parsePaymentRequest } from 'ln-service' -import crypto, { timingSafeEqual } from 'crypto' +import crypto from 'crypto' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { SELECT, itemQueryWithMeta } from './item' @@ -25,6 +25,8 @@ import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/ import { getNodeSockets, getOurPubkey } from '../lnd' import validateWallet from '@/wallets/validate' import { canReceive } from '@/wallets/common' +import { verifyHmac } from '@/lib/crypto' +import { addWalletLog } from '@/wallets/server' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') @@ -126,7 +128,11 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) { }, include: { user: true, - invoiceForward: true + invoiceForward: { + include: { + invoice: true + } + } } }) @@ -146,14 +152,6 @@ export function createHmac (hash) { return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') } -export function verifyHmac (hash, hmac) { - const hmac2 = createHmac(hash) - if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) { - throw new GqlAuthorizationError('bad hmac') - } - return true -} - const resolvers = { Query: { invoice: getInvoice, @@ -501,8 +499,12 @@ const resolvers = { createWithdrawl: createWithdrawal, sendToLnAddr, cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => { - verifyHmac(hash, hmac) - await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) + if (!verifyHmac(hash, hmac)) { + throw new GqlAuthorizationError('bad hmac') + } + if (!hash.startsWith('internal')) { // ignore internal invoices + await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) + } return await models.invoice.findFirst({ where: { hash } }) }, dropBolt11: async (parent, { id }, { me, models, lnd }) => { @@ -663,14 +665,6 @@ const resolvers = { export default injectResolvers(resolvers) -export const addWalletLog = async ({ wallet, level, message }, { models }) => { - try { - await models.walletLog.create({ data: { userId: wallet.userId, wallet: wallet.type, level, message } }) - } catch (err) { - console.error('error creating wallet log:', err) - } -} - async function upsertWallet ( { wallet, testCreateInvoice }, { settings, data, vaultEntries }, { me, models }) { if (!me) { diff --git a/api/typeDefs/paidAction.js b/api/typeDefs/paidAction.js index a38180a396..d6c89c0068 100644 --- a/api/typeDefs/paidAction.js +++ b/api/typeDefs/paidAction.js @@ -7,7 +7,7 @@ extend type Query { } extend type Mutation { - retryPaidAction(invoiceId: Int!): PaidAction! + retryPaidAction(invoiceId: Int!, forceInternal: Boolean, resetAttempts: Boolean, prioritizeInternal: Boolean): PaidAction! } enum PaymentMethod { @@ -21,36 +21,42 @@ enum PaymentMethod { interface PaidAction { invoice: Invoice paymentMethod: PaymentMethod! + retriableWithWallet: Boolean } type ItemPaidAction implements PaidAction { result: Item invoice: Invoice paymentMethod: PaymentMethod! + retriableWithWallet: Boolean } type ItemActPaidAction implements PaidAction { result: ItemActResult invoice: Invoice paymentMethod: PaymentMethod! + retriableWithWallet: Boolean } type PollVotePaidAction implements PaidAction { result: PollVoteResult invoice: Invoice paymentMethod: PaymentMethod! + retriableWithWallet: Boolean } type SubPaidAction implements PaidAction { result: Sub invoice: Invoice paymentMethod: PaymentMethod! + retriableWithWallet: Boolean } type DonatePaidAction implements PaidAction { result: DonateResult invoice: Invoice paymentMethod: PaymentMethod! + retriableWithWallet: Boolean } ` diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 4cd011cf26..25b87329ab 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -124,6 +124,11 @@ const typeDefs = ` itemAct: ItemAct } + type InvoiceForward { + invoice: Invoice + } + + type Withdrawl { id: ID! createdAt: Date! @@ -137,6 +142,7 @@ const typeDefs = ` autoWithdraw: Boolean! p2p: Boolean! preimage: String + invoiceForward: [InvoiceForward] } type Fact { diff --git a/components/item-act.js b/components/item-act.js index 36d5a0c777..231aa38e08 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -272,7 +272,7 @@ export function useZap () { const sats = nextTip(meSats, { ...me?.privates }) const variables = { id: item.id, sats, act: 'TIP' } - const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } } + const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables }, retriableWithWallet: true } } try { await abortSignal.pause({ me, amount: sats }) diff --git a/components/item-info.js b/components/item-info.js index 921d57f5dd..a60acdee60 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -277,7 +277,7 @@ function PaymentInfo ({ item, disableRetry, setDisableRetry }) { if (disableDualRetry) return setDisableDualRetry(true) try { - const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } }) + const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id), resetAttempts: true } }) if (error) throw error } catch (error) { toaster.danger(error.message) diff --git a/components/notifications.js b/components/notifications.js index 7fb10dbe6d..83693256e9 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -326,10 +326,10 @@ function NostrZap ({ n }) { ) } -function InvoicePaid ({ n }) { +function getPayerSig (lud18Data) { let payerSig - if (n.invoice.lud18Data) { - const { name, identifier, email, pubkey } = n.invoice.lud18Data + if (lud18Data) { + const { name, identifier, email, pubkey } = lud18Data const id = identifier || email || pubkey payerSig = '- ' if (name) { @@ -339,6 +339,11 @@ function InvoicePaid ({ n }) { if (id) payerSig += id } + return payerSig +} + +function InvoicePaid ({ n }) { + const payerSig = getPayerSig(n.invoice.lud18Data) return (
{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account @@ -359,6 +364,7 @@ function useActRetry ({ invoice }) { : {} return useAct({ query: RETRY_PAID_ACTION, + alwaysShowQROnFailure: true, onPayError: (e, cache, { data }) => { paidActionCacheMods?.onPayError?.(e, cache, { data }) bountyCacheMods?.onPayError?.(e, cache, { data }) @@ -464,7 +470,7 @@ function Invoicification ({ n: { invoice, sortTime } }) { if (disableRetry) return setDisableRetry(true) try { - const { error } = await retry({ variables: { invoiceId: parseInt(invoiceId) } }) + const { error } = await retry({ variables: { invoiceId: parseInt(invoiceId), resetAttempts: true } }) if (error) throw error } catch (error) { toaster.danger(error?.message || error?.toString?.()) @@ -484,6 +490,7 @@ function Invoicification ({ n: { invoice, sortTime } }) { } function WithdrawlPaid ({ n }) { + const payerSig = getPayerSig(n.withdrawl.invoiceForward?.[0]?.invoice?.lud18Data) return (
{numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })} @@ -491,6 +498,11 @@ function WithdrawlPaid ({ n }) { {timeSince(new Date(n.sortTime))} {(n.withdrawl.p2p && p2p) || (n.withdrawl.autoWithdraw && autowithdraw)} + {n.withdrawl.invoiceForward?.[0]?.invoice?.comment && + + {n.withdrawl.invoiceForward[0].invoice.comment} + {payerSig} + }
) } diff --git a/components/pay-bounty.js b/components/pay-bounty.js index 35c5ebe602..db7f63d1cf 100644 --- a/components/pay-bounty.js +++ b/components/pay-bounty.js @@ -50,7 +50,7 @@ export default function PayBounty ({ children, item }) { const variables = { id: item.id, sats: root.bounty, act: 'TIP' } const act = useAct({ variables, - optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } }, + optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path }, retriableWithWallet: true } }, ...payBountyCacheMods }) diff --git a/components/payment.js b/components/payment.js index 175ca2b3bf..7e4dbba20e 100644 --- a/components/payment.js +++ b/components/payment.js @@ -112,9 +112,10 @@ const invoiceController = (id, isInvoice) => { export const useWalletPayment = () => { const invoice = useInvoice() - const wallet = useWallet() + const defaultWallet = useWallet() - const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => { + const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor, wallet) => { + wallet = wallet ?? defaultWallet if (!wallet) { throw new NoAttachedWalletError() } @@ -134,7 +135,7 @@ export const useWalletPayment = () => { } finally { controller.stop() } - }, [wallet, invoice]) + }, [defaultWallet, invoice]) return waitForWalletPayment } diff --git a/components/poll.js b/components/poll.js index dc694f801c..35539239ae 100644 --- a/components/poll.js +++ b/components/poll.js @@ -23,7 +23,7 @@ export default function Poll ({ item }) { onClick={me ? async () => { const variables = { id: v.id } - const optimisticResponse = { pollVote: { __typename: 'PollVotePaidAction', result: { id: v.id } } } + const optimisticResponse = { pollVote: { __typename: 'PollVotePaidAction', result: { id: v.id }, retriableWithWallet: true } } try { const { error } = await pollVote({ variables, @@ -44,7 +44,7 @@ export default function Poll ({ item }) { } const RetryVote = () => { - const retryVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: item.id }) + const retryVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: item.id, alwaysShowQROnFailure: true }) const waitForQrPayment = useQrPayment() if (item.poll.meInvoiceActionState === 'PENDING') { @@ -103,7 +103,7 @@ function PollResult ({ v, progress }) { ) } -export function usePollVote ({ query = POLL_VOTE, itemId }) { +export function usePollVote ({ query = POLL_VOTE, itemId, ...options }) { const update = (cache, { data }) => { // the mutation name varies for optimistic retries const response = Object.values(data)[0] @@ -185,6 +185,6 @@ export function usePollVote ({ query = POLL_VOTE, itemId }) { }) } - const [pollVote] = usePaidMutation(query, { update, onPayError, onPaid }) + const [pollVote] = usePaidMutation(query, { update, onPayError, onPaid, ...options }) return pollVote } diff --git a/components/qr.js b/components/qr.js index 23757ba33d..a4220ef71f 100644 --- a/components/qr.js +++ b/components/qr.js @@ -2,8 +2,9 @@ import { QRCodeSVG } from 'qrcode.react' import { CopyInput, InputSkeleton } from './form' import InvoiceStatus from './invoice-status' import { useEffect } from 'react' -import { useWallet } from '@/wallets/index' +import { useWallets } from '@/wallets/index' import Bolt11Info from './bolt11-info' +import { canSend } from '@/wallets/common' export const qrImageSettings = { src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E', @@ -16,20 +17,25 @@ export const qrImageSettings = { export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) { const qrValue = asIs ? value : 'lightning:' + value.toUpperCase() - const wallet = useWallet() + const { wallets } = useWallets() useEffect(() => { async function effect () { - if (automated && wallet) { - try { - await wallet.sendPayment(value) - } catch (e) { - console.log(e?.message) + const usableWallets = wallets.filter(w => !w.def.isAvailable || w.def.isAvailable()) + .filter(w => w.config?.enabled && canSend(w))[0] + if (automated && usableWallets.length > 0) { + for (const wallet of usableWallets) { + try { + await wallet.sendPayment(value) + break + } catch (e) { + console.log(e?.message) + } } } } effect() - }, [wallet]) + }, [wallets]) return ( <> diff --git a/components/use-item-submit.js b/components/use-item-submit.js index f460c51aa1..9afd1f4e8e 100644 --- a/components/use-item-submit.js +++ b/components/use-item-submit.js @@ -101,6 +101,7 @@ export function useRetryCreateItem ({ id }) { const [retryPaidAction] = usePaidMutation( RETRY_PAID_ACTION, { + alwaysShowQROnFailure: true, ...paidActionCacheMods, update: (cache, { data }) => { const response = Object.values(data)[0] diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index 765508b5f1..bc6ef1479f 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -1,9 +1,11 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { useCallback, useState } from 'react' import { useInvoice, useQrPayment, useWalletPayment } from './payment' -import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' -import { GET_PAID_ACTION } from '@/fragments/paidAction' - +import { InvoiceCanceledError } from '@/wallets/errors' +import { GET_PAID_ACTION, RETRY_PAID_ACTION } from '@/fragments/paidAction' +import { useWallets, useWallet } from '@/wallets/index' +import { canSend } from '@/wallets/common' +import { useMe } from './me' /* this is just like useMutation with a few changes: 1. pays an invoice returned by the mutation @@ -23,39 +25,130 @@ export function usePaidMutation (mutation, const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, { fetchPolicy: 'network-only' }) + const [retryPaidAction] = useMutation(RETRY_PAID_ACTION) const waitForWalletPayment = useWalletPayment() const invoiceHelper = useInvoice() const waitForQrPayment = useQrPayment() const client = useApolloClient() // innerResult is used to store/control the result of the mutation when innerMutate runs const [innerResult, setInnerResult] = useState(result) + const { wallets: walletDefs } = useWallets() + const { me } = useMe() + + const addPayError = (e, rest) => ({ + ...rest, + payError: e, + error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined + }) + + // walletDefs shouldn't change on rerender, so it should be safe + const usableWallets = walletDefs + .map(w => useWallet(w.def.name)) + .filter(w => !w.def.isAvailable || w.def.isAvailable()) + .filter(w => w.config?.enabled && canSend(w)) + + const waitForActionPayment = useCallback(async (invoice, { wallet, alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }, originalResponse, action) => { + const walletErrors = [] + let response = originalResponse + let invoiceUsed = false + + // ensures every invoice is used only once + const refreshInvoice = async () => { + if (invoiceUsed) { + const retry = await retryPaidAction({ variables: { invoiceId: parseInt(invoice.id) } }) + response = retry.data?.retryPaidAction + invoice = response?.invoice + invoiceUsed = true + } else invoiceUsed = true + } + + const cancelInvoice = async () => { + try { + invoiceUsed = true + await invoiceHelper.cancel(invoice) + console.log('old invoice canceled') + } catch (err) { + console.error('could not cancel old invoice', err) + } + } + + // if anon we go straight to qr code + if (!me) { + await refreshInvoice() + if (!invoice) { + setInnerResult(r => addPayError(new Error('You must be logged in'), r)) + throw new Error('You must be logged in') + } + await waitForQrPayment(invoice, null, { persistOnNavigate, waitFor }) + return { invoice, response } + } + + // we try every sender<->receiver combination + while (true) { + await refreshInvoice() + if (!invoice) return { invoice, response } + + for (const wallet of usableWallets) { + try { + await waitForWalletPayment(invoice, waitFor, wallet) + console.log('paid with wallet', wallet.def.name) + return { invoice, response } + } catch (err) { + walletErrors.push(err) + console.log('could not pay with wallet', wallet.def.name, 'will try another one ...') + } + } - const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => { - let walletError - const start = Date.now() + // every wallet failed, but we can still retry, so let's ask for a better invoice + console.log(response) + if (response.retriableWithWallet) { + console.log('could not pay with any wallet, will retry with a new invoice...') + } else { + console.log('could not pay with any wallet, and the invoice is not retriable, will fallback to another method') + break + } + } + + // we try an internal payment + console.log(response) try { - return await waitForWalletPayment(invoice, waitFor) - } catch (err) { - if ( - (!alwaysShowQROnFailure && Date.now() - start > 1000) || - err instanceof InvoiceCanceledError || - err instanceof InvoiceExpiredError) { - // bail since qr code payment will also fail - // also bail if the payment took more than 1 second - // and cancel the invoice if it's not already canceled so it can be retried - invoiceHelper.cancel(invoice).catch(console.error) - throw err + console.log('could not pay with any wallet, will try with an internal payment...') + const retry = await retryPaidAction({ variables: { invoiceId: parseInt(invoice.id), prioritizeInternal: true } }) + response = retry.data?.retryPaidAction + invoice = response?.invoice + if (!invoice) { + return { response } + } else { + // if the internal payment returned an invoice, it means it failed + // maybe the user doesn't have enough credits. + invoiceUsed = false } - walletError = err + } catch (err) { + console.log('could not pay with internal payment, will fallback to another method') + walletErrors.push(err) } - return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor }) - }, [waitForWalletPayment, waitForQrPayment, invoiceHelper]) + + // last resort, show qr code or fail + console.log(response) + if ((alwaysShowQROnFailure || usableWallets.length === 0)) { + console.log('show qr code for manual payment') + await refreshInvoice() + // if we get here, we've exhausted all retries and the last invoice is still pending, so we will try to pay it with a qr code + await waitForQrPayment(invoice, walletErrors[walletErrors.length - 1], { persistOnNavigate, waitFor }) + } else { + console.log('we are out of options, we will throw the errors') + cancelInvoice().catch(console.error) + throw new Error(walletErrors.map(e => e.message).join('\n')) + } + + return { invoice, response } + }, [waitForWalletPayment, waitForQrPayment, invoiceHelper, usableWallets]) const innerMutate = useCallback(async ({ - onCompleted: innerOnCompleted, ...innerOptions + onCompleted: innerOnCompleted, resetAttempts, ...innerOptions } = {}) => { innerOptions.optimisticResponse = addOptimisticResponseExtras(innerOptions.optimisticResponse) - let { data, ...rest } = await mutate(innerOptions) + let { data, ...rest } = await mutate({ ...innerOptions, resetAttempts }) // use the most inner callbacks/options if they exist const { @@ -75,62 +168,52 @@ export function usePaidMutation (mutation, if (invoice) { // adds payError, escalating to a normal error if the invoice is not canceled or // has an actionError - const addPayError = (e, rest) => ({ - ...rest, - payError: e, - error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined - }) + const wait = response?.paymentMethod !== 'OPTIMISTIC' || forceWaitForPayment + const alwaysShowQROnFailure = options.alwaysShowQROnFailure ?? innerOptions.alwaysShowQROnFailure ?? wait // should we wait for the invoice to be paid? - if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) { + if (!wait) { // onCompleted is called before the invoice is paid for optimistic updates ourOnCompleted?.(data) - // don't wait to pay the invoice - waitForPayment(invoice, { persistOnNavigate, waitFor }).then(() => { - onPaid?.(client.cache, { data }) - }).catch(e => { - console.error('usePaidMutation: failed to pay invoice', e) - // onPayError is called after the invoice fails to pay - // useful for updating invoiceActionState to FAILED - onPayError?.(e, client.cache, { data }) - setInnerResult(r => addPayError(e, r)) - }) } else { - // the action is pessimistic - try { - // wait for the invoice to be paid - await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor }) - if (!response.result) { - // if the mutation didn't return any data, ie pessimistic, we need to fetch it - const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } }) - // create new data object - // ( hmac is only returned on invoice creation so we need to add it back to the data ) - data = { - [Object.keys(data)[0]]: { - ...paidAction, - invoice: { ...paidAction.invoice, hmac: invoice.hmac } - } + setInnerResult({ data, ...rest }) + } + // don't wait to pay the invoice + const p = waitForActionPayment(invoice, { alwaysShowQROnFailure, persistOnNavigate, waitFor }, response, innerOptions).then(async ({ invoice, response }) => { + if (!response.result) { // supposedly this is never the case for optimistic actions + // if the mutation didn't return any data, ie pessimistic, we need to fetch it + const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } }) + // create new data object + // ( hmac is only returned on invoice creation so we need to add it back to the data ) + data = { + [Object.keys(data)[0]]: { + ...paidAction, + invoice: { ...paidAction.invoice, hmac: invoice.hmac } } - // we need to run update functions on mutations now that we have the data - update?.(client.cache, { data }) } - ourOnCompleted?.(data) - onPaid?.(client.cache, { data }) - } catch (e) { - console.error('usePaidMutation: failed to pay invoice', e) - onPayError?.(e, client.cache, { data }) - rest = addPayError(e, rest) + // we need to run update functions on mutations now that we have the data + update?.(client.cache, { data }) } - } + if (wait) ourOnCompleted?.(data) + onPaid?.(client.cache, { data }) + setInnerResult({ data, ...rest }) + }).catch(e => { + console.error('usePaidMutation: failed to pay invoice', e) + // onPayError is called after the invoice fails to pay + // useful for updating invoiceActionState to FAILED + onPayError?.(e, client.cache, { data }) + setInnerResult(r => addPayError(e, r)) + }) + + if (wait) await p } else { // fee credits paid for it ourOnCompleted?.(data) onPaid?.(client.cache, { data }) } - setInnerResult({ data, ...rest }) return { data, ...rest } - }, [mutate, options, waitForPayment, onCompleted, client.cache, getPaidAction, setInnerResult]) + }, [mutate, options, waitForActionPayment, onCompleted, client.cache, getPaidAction, setInnerResult]) return [innerMutate, innerResult] } @@ -139,7 +222,7 @@ export function usePaidMutation (mutation, function addOptimisticResponseExtras (optimisticResponse) { if (!optimisticResponse) return optimisticResponse const key = Object.keys(optimisticResponse)[0] - optimisticResponse[key] = { invoice: null, paymentMethod: 'OPTIMISTIC', ...optimisticResponse[key] } + optimisticResponse[key] = { invoice: null, paymentMethod: 'OPTIMISTIC', retriableWithWallet: true, ...optimisticResponse[key] } return optimisticResponse } diff --git a/fragments/notifications.js b/fragments/notifications.js index ce588ccc14..a92a5ae966 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -186,8 +186,16 @@ export const NOTIFICATIONS = gql` withdrawl { autoWithdraw p2p - satsFeePaid - } + satsFeePaid + invoiceForward { + invoice { + id + nostr + comment + lud18Data + } + } + } } ... on Reminder { id diff --git a/fragments/paidAction.js b/fragments/paidAction.js index c47fa7005c..335ecbf3e0 100644 --- a/fragments/paidAction.js +++ b/fragments/paidAction.js @@ -13,6 +13,7 @@ export const PAID_ACTION = gql` ...InvoiceFields } paymentMethod + retriableWithWallet }` const ITEM_PAID_ACTION_FIELDS = gql` @@ -88,8 +89,10 @@ export const RETRY_PAID_ACTION = gql` ${PAID_ACTION} ${ITEM_PAID_ACTION_FIELDS} ${ITEM_ACT_PAID_ACTION_FIELDS} - mutation retryPaidAction($invoiceId: Int!) { - retryPaidAction(invoiceId: $invoiceId) { + ${SUB_FULL_FIELDS} + + mutation retryPaidAction($invoiceId: Int!, $forceInternal: Boolean, $resetAttempts: Boolean, $prioritizeInternal: Boolean) { + retryPaidAction(invoiceId: $invoiceId, forceInternal: $forceInternal, resetAttempts: $resetAttempts, prioritizeInternal: $prioritizeInternal) { __typename ...PaidActionFields ... on ItemPaidAction { @@ -103,6 +106,16 @@ export const RETRY_PAID_ACTION = gql` id } } + ... on SubPaidAction { + result { + ...SubFullFields + } + } + ... on DonatePaidAction { + result { + sats + } + } } }` diff --git a/lib/apollo.js b/lib/apollo.js index d72a035d5d..af0e0c1ec3 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -49,7 +49,8 @@ function getClient (uri) { 'ItemActPaidAction', 'PollVotePaidAction', 'SubPaidAction', - 'DonatePaidAction' + 'DonatePaidAction', + 'TransferPaidAction' ], Notification: [ 'Reply', diff --git a/lib/constants.js b/lib/constants.js index 1dbabdb9ed..2ccce20126 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -8,7 +8,8 @@ export const PAID_ACTION_PAYMENT_METHODS = { FEE_CREDIT: 'FEE_CREDIT', PESSIMISTIC: 'PESSIMISTIC', OPTIMISTIC: 'OPTIMISTIC', - P2P: 'P2P' + P2P: 'P2P', + ZERO_COST: 'ZERO_COST' } export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING'] export const NOFOLLOW_LIMIT = 1000 @@ -189,3 +190,11 @@ export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTER export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL) export const ZAP_UNDO_DELAY_MS = 5_000 + +// the fee for the zap sybil service +export const ZAP_SYBIL_FEE_PERCENT = 30n +export const MAX_FEE_ESTIMATE_PERMILE = 25n // the maximum fee relative to outgoing we'll allow for the fee estimate + +export const INVOICE_EXPIRE_SECS = 600 +export const MAX_PENDING_INVOICES_PER_WALLET = 25 +export const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 diff --git a/lib/crypto.js b/lib/crypto.js index d1812cbb90..97a8c6e2de 100644 --- a/lib/crypto.js +++ b/lib/crypto.js @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import crypto, { createHash, timingSafeEqual } from 'node:crypto' export function hashEmail ({ email, @@ -7,3 +7,13 @@ export function hashEmail ({ const saltedEmail = `${email.toLowerCase()}${salt}` return createHash('sha256').update(saltedEmail).digest('hex') } + +export function createHmac (hash) { + const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex') + return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') +} + +export function verifyHmac (hash, hmac) { + const hmac2 = createHmac(hash) + return timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2)) +} diff --git a/lib/proxy.js b/lib/proxy.js index ea8c0a87d1..ff08f8fe67 100644 --- a/lib/proxy.js +++ b/lib/proxy.js @@ -137,9 +137,9 @@ export function getAgent ({ hostname, cert }) { return agent } - if (process.env.NODE_ENV === 'development' && !cert) { - return new http.Agent() - } + // if (process.env.NODE_ENV === 'development' && !cert) { + // return new http.Agent() + // } // we only support HTTPS over clearnet return new https.Agent(httpsAgentOptions) diff --git a/lib/validate.js b/lib/validate.js index c485ac4081..23141d78f1 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -493,27 +493,6 @@ export const lud18PayerDataSchema = (k1) => object({ email: string().email('bad email address'), identifier: string() }) - -// check if something is _really_ a number. -// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity] -export const isNumber = x => typeof x === 'number' && !Number.isNaN(x) - -export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => { - if (typeof x === 'undefined') { - throw new Error('value is required') - } - const n = Number(x) - if (isNumber(n)) { - if (x < min || x > max) { - throw new Error(`value ${x} must be between ${min} and ${max}`) - } - return n - } - throw new Error(`value ${x} is not a number`) -} - -export const toPositiveNumber = (x) => toNumber(x, 0) - export const deviceSyncSchema = object().shape({ passphrase: string().required('required') .test(async (value, context) => { @@ -533,3 +512,83 @@ export const deviceSyncSchema = object().shape({ return true }) }) + +// check if something is _really_ a number. +// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity] +export const isNumber = x => typeof x === 'number' && !Number.isNaN(x) + +/** + * + * @param {any | bigint} x + * @param {number} min + * @param {number} max + * @returns {number} + */ +export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => { + if (typeof x === 'undefined') { + throw new Error('value is required') + } + if (typeof x === 'bigint') { + if (x < BigInt(min) || x > BigInt(max)) { + throw new Error(`value ${x} must be between ${min} and ${max}`) + } + return Number(x) + } else { + const n = Number(x) + if (isNumber(n)) { + if (x < min || x > max) { + throw new Error(`value ${x} must be between ${min} and ${max}`) + } + return n + } + } + throw new Error(`value ${x} is not a number`) +} + +/** + * @param {number | bigint} x + * @returns {number} + */ +export const toPositiveNumber = (x) => { + return toNumber(x, 0) +} + +/** + * @param {any} x + * @param {bigint | number} [min] + * @param {bigint | number} [max] + * @returns {bigint} + */ +export const toBigInt = (x, min, max) => { + if (typeof x === 'undefined') throw new Error('value is required') + min = min !== undefined ? BigInt(min) : undefined + max = max !== undefined ? BigInt(max) : undefined + + const n = BigInt(x) + if (min !== undefined && n < min) { + throw new Error(`value ${x} must be at least ${min}`) + } + + if (max !== undefined && n > max) { + throw new Error(`value ${x} must be at most ${max}`) + } + + return n +} + +/** + * @param {number|bigint} x + * @returns {bigint} + */ +export const toPositiveBigInt = (x) => { + return toBigInt(x, 0) +} + +/** + * @param {number|bigint} x + * @returns {number|bigint} + */ +export const toPositive = (x) => { + if (typeof x === 'bigint') return toPositiveBigInt(x) + return toPositiveNumber(x) +} diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 6cfc9b448b..9648167324 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -1,14 +1,13 @@ import models from '@/api/models' import lnd from '@/api/lnd' -import { createInvoice } from 'ln-service' import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '@/lib/lnurl' -import serialize from '@/api/resolvers/serial' import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' -import { datePivot } from '@/lib/time' -import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants' -import { validateSchema, lud18PayerDataSchema } from '@/lib/validate' +import { LNURLP_COMMENT_MAX_LENGTH } from '@/lib/constants' +import { validateSchema, lud18PayerDataSchema, toPositiveNumber } from '@/lib/validate' import assertGofacYourself from '@/api/resolvers/ofac' +import performPaidAction from '@/api/paidAction' +import { msatsToSats } from '@/lib/format' export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => { const user = await models.user.findUnique({ where: { name: username } }) @@ -30,14 +29,15 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa // If there is an amount tag, it MUST be equal to the amount query parameter const eventAmount = note.tags?.find(t => t[0] === 'amount')?.[1] if (schnorr.verify(note.sig, note.id, note.pubkey) && hasPTag && hasETag && (!eventAmount || Number(eventAmount) === Number(amount))) { - description = user.hideInvoiceDesc ? undefined : 'zap' + description = 'zap' descriptionHash = createHash('sha256').update(noteStr).digest('hex') } else { res.status(400).json({ status: 'ERROR', reason: 'invalid NIP-57 note' }) return } } else { - description = user.hideInvoiceDesc ? undefined : `Funding @${username} on stacker.news` + description = `Funding @${username} on stacker.news` + description += comment ? `: ${comment}` : '.' descriptionHash = lnurlPayDescriptionHashForUser(username) } @@ -45,7 +45,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' }) } - if (comment && comment.length > LNURLP_COMMENT_MAX_LENGTH) { + if ((comment && comment.length > LNURLP_COMMENT_MAX_LENGTH) || (description && description.length > LNURLP_COMMENT_MAX_LENGTH)) { return res.status(400).json({ status: 'ERROR', reason: `comment cannot exceed ${LNURLP_COMMENT_MAX_LENGTH} characters in length` }) } @@ -71,27 +71,20 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa } // generate invoice - const expiresAt = datePivot(new Date(), { minutes: 5 }) - const invoice = await createInvoice({ + const actionResponse = await performPaidAction('TRANSFER', { + sats: toPositiveNumber(msatsToSats(BigInt(amount))), description, - description_hash: descriptionHash, - lnd, - mtokens: amount, - expires_at: expiresAt - }) + descriptionHash, + comment: comment || '', + targetUserId: user.id + }, { models, lnd, me: user, disableFeeCredit: true }) - await serialize( - models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.secret}::TEXT, ${invoice.request}, - ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description}, - ${comment || null}, ${parsedPayerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER, - ${USER_IDS_BALANCE_NO_LIMIT.includes(Number(user.id)) ? 0 : BALANCE_LIMIT_MSATS})`, - { models } - ) + if (!actionResponse.invoice?.bolt11) throw new Error('could not generate invoice') return res.status(200).json({ - pr: invoice.request, + pr: actionResponse.invoice.bolt11, routes: [], - verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.id}` + verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${actionResponse.invoice.hash}` }) } catch (error) { console.log(error) diff --git a/prisma/migrations/20241029160827_transfer_paid_action/migration.sql b/prisma/migrations/20241029160827_transfer_paid_action/migration.sql new file mode 100644 index 0000000000..a10c9d17e7 --- /dev/null +++ b/prisma/migrations/20241029160827_transfer_paid_action/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "InvoiceActionType" ADD VALUE 'TRANSFER'; \ No newline at end of file diff --git a/prisma/migrations/20241106101356_retriable_invoiced_paidactions/migration.sql b/prisma/migrations/20241106101356_retriable_invoiced_paidactions/migration.sql new file mode 100644 index 0000000000..c861f00ebc --- /dev/null +++ b/prisma/migrations/20241106101356_retriable_invoiced_paidactions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "actionAttempt" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1279d995bb..4a6c132b98 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -864,6 +864,7 @@ enum InvoiceActionType { TERRITORY_UPDATE TERRITORY_BILLING TERRITORY_UNARCHIVE + TRANSFER } enum InvoiceActionState { @@ -922,6 +923,8 @@ model Invoice { actionArgs Json? @db.JsonB actionError String? actionResult Json? @db.JsonB + actionAttempt Int @default(0) + ItemAct ItemAct[] Item Item[] Upload Upload[] diff --git a/wallets/server.js b/wallets/server.js index 91a68a1ba8..8948703de0 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -11,113 +11,169 @@ import * as blink from 'wallets/blink/server' import * as lnc from 'wallets/lnc' import * as webln from 'wallets/webln' -import { addWalletLog } from '@/api/resolvers/wallet' -import walletDefs from 'wallets/server' import { parsePaymentRequest } from 'ln-service' -import { toPositiveNumber } from '@/lib/validate' -import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' +import { PAID_ACTION_TERMINAL_STATES, INVOICE_EXPIRE_SECS, MAX_PENDING_INVOICES_PER_WALLET, ZAP_SYBIL_FEE_PERCENT } from '@/lib/constants' +import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate' import { withTimeout } from '@/lib/time' import { canReceive } from './common' +import wrapInvoice from 'wallets/wrap' +import assert from 'assert' + +const walletDefs = [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] +export default walletDefs + +export const SN_WALLET = null + +export async function createHodlInvoice ( + userId, + { + msats, + description, + descriptionHash, + expiry = INVOICE_EXPIRE_SECS, + sybilFeePercent, + direct = false, + attempt = 0 + }, + { models, lnd, useWallet } +) { + return createInvoice(userId, { + msats, + description, + descriptionHash, + expiry, + sybilFeePercent, + hodl: true, + direct + }, { models, lnd, useWallet }) +} -export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] - -const MAX_PENDING_INVOICES_PER_WALLET = 25 - -export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) { - // get the wallets in order of priority - const wallets = await models.wallet.findMany({ - where: { userId, enabled: true }, - include: { - user: true - }, - orderBy: [ - { priority: 'asc' }, - // use id as tie breaker (older wallet first) - { id: 'asc' } - ] - }) +export async function createInvoice ( + receiverUserId, + { + msats, + description, + descriptionHash, + expiry = INVOICE_EXPIRE_SECS, + sybilFeePercent, + hodl = false, + direct = false, + attempt = 0 + }, + { models, lnd, useWallet } +) { + if (expiry < INVOICE_EXPIRE_SECS) console.warn('The invoice might be expiring too early', expiry, 'default:', INVOICE_EXPIRE_SECS) + msats = toPositiveBigInt(msats) + + if (!receiverUserId) { + throw new Error('no receiverUserId provided') + } - msats = toPositiveNumber(msats) + // we are creating an invoice paid to an user + const receiverUser = await models.user.findUnique({ where: { id: receiverUserId } }) + if (!receiverUser) throw new Error('user not found') + + // respect receiver's privacy settings + if (receiverUser.hideInvoiceDesc) { + description = undefined + descriptionHash = undefined + } + + const wallets = + useWallet + ? [useWallet] // if a wallet is specified, use it + : await models.wallet.findMany({ + where: { userId: receiverUserId, enabled: true }, + orderBy: [ + { priority: 'asc' }, + // use id as tie breaker (older wallet first) + { id: 'asc' } + ] + }) + + let i = attempt + if (i >= wallets.length) i = 0 + for (; i < wallets.length; i++) { + const wallet = wallets[i] - for (const wallet of wallets) { const w = walletDefs.find(w => w.walletType === wallet.type) try { + if (!w) throw new Error(`wallet type ${wallet.type} not found`) if (!canReceive({ def: w, config: wallet.wallet })) { continue } - const { walletType, walletField, createInvoice } = w const walletFull = await models.wallet.findFirst({ where: { - userId, + userId: receiverUserId, type: walletType }, include: { [walletField]: true } }) - - if (!walletFull || !walletFull[walletField]) { - throw new Error(`no ${walletType} wallet found`) - } - - // check for pending withdrawals - const pendingWithdrawals = await models.withdrawl.count({ - where: { - walletId: walletFull.id, - status: null - } - }) - - // and pending forwards - const pendingForwards = await models.invoiceForward.count({ - where: { - walletId: walletFull.id, - invoice: { - actionState: { - notIn: PAID_ACTION_TERMINAL_STATES - } - } - } - }) - - console.log('pending invoices', pendingWithdrawals + pendingForwards) - if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) { - throw new Error('wallet has too many pending invoices') - } - console.log('use wallet', walletType) - - const invoice = await withTimeout( - createInvoice({ + await checkWallet(models, walletFull, walletField, walletType) + + console.log('use wallet', wallet.type) + if (hodl || !direct) { + // we always wrap p2p invoices unless it is a direct non-hodl invoice + const wrappedMsats = msats + const innerMsats = BigInt(msats) * (100n - BigInt(sybilFeePercent ?? ZAP_SYBIL_FEE_PERCENT)) / 100n + assert(innerMsats <= wrappedMsats, 'innerMsats must be less than or equal to wrappedMsats') + + const invoice = await withTimeout( + createInvoice({ + msats: toPositiveNumber(innerMsats), + description, + descriptionHash, + expiry + }, walletFull[walletField]), 10_000) + await checkInvoice(models, wallet, invoice, innerMsats) + if (!lnd) throw new Error('no lnd provided for wrapping') + const { invoice: wrappedInvoice, maxFee } = await wrapInvoice(invoice, + { + msats: wrappedMsats, + description: !descriptionHash ? description : undefined, + descriptionHash + }, { lnd }, sybilFeePercent) + await checkInvoice(models, wallet, wrappedInvoice.request, wrappedMsats) + return { + innerInvoice: invoice, + invoice: wrappedInvoice.request, + wallet, + maxFee, + isWrapped: true, + isHodl: true, msats, - description: wallet.user.hideInvoiceDesc ? undefined : description, - descriptionHash, - expiry - }, walletFull[walletField]), 10_000) - - const bolt11 = await parsePaymentRequest({ request: invoice }) - if (BigInt(bolt11.mtokens) !== BigInt(msats)) { - if (BigInt(bolt11.mtokens) > BigInt(msats)) { - throw new Error(`invoice is for an amount greater than requested ${bolt11.mtokens} > ${msats}`) - } - if (BigInt(bolt11.mtokens) === 0n) { - throw new Error('invoice is for 0 msats') + description, + retriable: i < wallets.length - 1, + attempt } - if (BigInt(msats) - BigInt(bolt11.mtokens) >= 1000n) { - throw new Error(`invoice has a different satoshi amount ${bolt11.mtokens} !== ${msats}`) - } - - await addWalletLog({ + } else { + // return a regular invoice straight from the attached wallet + const invoice = await withTimeout( + createInvoice({ + msats, + description, + descriptionHash, + expiry + }, walletFull[walletField]), 10_000) + await checkInvoice(models, wallet, invoice, msats) + + return { + invoice, wallet, - level: 'INFO', - message: `wallet does not support msats so we floored ${msats} msats to nearest sat ${BigInt(bolt11.mtokens)} msats` - }, { models }) + isWrapped: false, + isHodl: false, + msats, + description, + retriable: i < wallets.length - 1, + attempt + } } - - return { invoice, wallet } } catch (error) { - console.error(error) + console.error('error checking wallet:', error) await addWalletLog({ wallet, level: 'ERROR', @@ -125,6 +181,63 @@ export async function createInvoice (userId, { msats, description, descriptionHa }, { models }) } } - throw new Error('no wallet available') } + +export const addWalletLog = async ({ wallet, level, message }, { models }) => { + try { + await models.walletLog.create({ data: { userId: wallet.userId, wallet: wallet.type, level, message } }) + } catch (err) { + console.error('error creating wallet log:', err) + } +} + +async function checkInvoice (models, wallet, invoice, msats) { + const bolt11 = await parsePaymentRequest({ request: invoice }) + if (BigInt(bolt11.mtokens) !== msats) { + if (BigInt(bolt11.mtokens) > msats) { + throw new Error(`invoice is for an amount greater than requested ${bolt11.mtokens} > ${msats}`) + } + if (BigInt(bolt11.mtokens) === 0n) { + throw new Error('invoice is for 0 msats') + } + if (msats - BigInt(bolt11.mtokens) >= 1000n) { + throw new Error(`invoice has a different satoshi amount ${bolt11.mtokens} !== ${msats}`) + } + + await addWalletLog({ + wallet, + level: 'INFO', + message: `wallet does not support msats so we floored ${msats} msats to nearest sat ${BigInt(bolt11.mtokens)} msats` + }, { models }) + } +} + +async function checkWallet (models, walletFull, walletField, walletType) { + if (!walletFull || !walletFull[walletField]) throw new Error(`no ${walletType} wallet found`) + + // check for pending withdrawals + const pendingWithdrawals = await models.withdrawl.count({ + where: { + walletId: walletFull.id, + status: null + } + }) + + // and pending forwards + const pendingForwards = await models.invoiceForward.count({ + where: { + walletId: walletFull.id, + invoice: { + actionState: { + notIn: PAID_ACTION_TERMINAL_STATES + } + } + } + }) + + console.log('pending invoices', pendingWithdrawals + pendingForwards) + if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) { + throw new Error('wallet has too many pending invoices') + } +} diff --git a/wallets/wrap.js b/wallets/wrap.js index b470587b96..1369740960 100644 --- a/wallets/wrap.js +++ b/wallets/wrap.js @@ -1,6 +1,7 @@ import { createHodlInvoice, parsePaymentRequest } from 'ln-service' import { estimateRouteFee, getBlockHeight } from '../api/lnd' -import { toPositiveNumber } from '@/lib/validate' +import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate' +import { ZAP_SYBIL_FEE_PERCENT, MAX_FEE_ESTIMATE_PERMILE } from '@/lib/constants' const MIN_OUTGOING_MSATS = BigInt(900) // the minimum msats we'll allow for the outgoing invoice const MAX_OUTGOING_MSATS = BigInt(900_000_000) // the maximum msats we'll allow for the outgoing invoice @@ -9,21 +10,34 @@ const INCOMING_EXPIRATION_BUFFER_MSECS = 300_000 // the buffer enforce for the i const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for the outgoing invoice export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request -const MAX_FEE_ESTIMATE_PERCENT = 0.025 // the maximum fee relative to outgoing we'll allow for the fee estimate -const ZAP_SYBIL_FEE_MULT = 10 / 7 // the fee for the zap sybil service -/* - The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice. - - @param bolt11 {string} the bolt11 invoice to wrap - @param options {object} - @returns { - invoice: the wrapped incoming invoice, - maxFee: number - } -*/ -export default async function wrapInvoice (bolt11, { msats, description, descriptionHash }, { me, lnd }) { +/** + * The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice. + * + * @typedef {import('lightning').CreateHodlInvoiceResult} CreateHodlInvoiceResult + + * @typedef {Object} WrapedInvoice + * @property {CreateHodlInvoiceResult} invoice the wrapped incoming invoice + * @property {bigint} maxFee the maximum fee allowed for the outgoing invoice + * + * + * @param {*} bolt11 the bolt11 invoice to wrap + * @param {Object} args + * @param {bigint} args.msats + * @param {string} args.description + * @param {string} [args.descriptionHash] + * @param {Object} context + * @param {Object} context.lnd + * @param {bigint} [zapSybilFeePercent] + * @returns {Promise} + */ +export default async function wrapInvoice (bolt11, { msats, description, descriptionHash }, { lnd }, zapSybilFeePercent) { try { + if (!zapSybilFeePercent) { + console.log('Use default zapSybilFeePercent', ZAP_SYBIL_FEE_PERCENT) + zapSybilFeePercent = ZAP_SYBIL_FEE_PERCENT + } + console.group('wrapInvoice', description) // create a new object to hold the wrapped invoice values @@ -40,7 +54,7 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip // validate outgoing amount if (inv.mtokens) { - outgoingMsat = toPositiveNumber(inv.mtokens) + outgoingMsat = toPositiveBigInt(inv.mtokens) if (outgoingMsat < MIN_OUTGOING_MSATS) { throw new Error(`Invoice amount is too low: ${outgoingMsat}`) } @@ -53,9 +67,12 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip // validate incoming amount if (msats) { - msats = toPositiveNumber(msats) - if (outgoingMsat * ZAP_SYBIL_FEE_MULT > msats) { + msats = toPositiveBigInt(msats) + const msatsCheck = outgoingMsat * 100n / (100n - zapSybilFeePercent) + if (msatsCheck > msats) { throw new Error('Sybil fee is too low') + } else if (msatsCheck < msats) { + console.warn('Sybil fee is too high') } } else { throw new Error('Incoming invoice amount is missing') @@ -112,11 +129,6 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip wrapped.description = inv.description } - if (me?.hideInvoiceDesc) { - wrapped.description = undefined - wrapped.description_hash = undefined - } - // validate the expiration if (new Date(inv.expires_at) < new Date(Date.now() + INCOMING_EXPIRATION_BUFFER_MSECS)) { throw new Error('Invoice expiration is too soon') @@ -161,8 +173,8 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip } // validate the fee budget - const minEstFees = toPositiveNumber(routingFeeMsat) - const outgoingMaxFeeMsat = Math.ceil(msats * MAX_FEE_ESTIMATE_PERCENT) + const minEstFees = toPositiveBigInt(routingFeeMsat) + const outgoingMaxFeeMsat = msats * MAX_FEE_ESTIMATE_PERMILE / 1000n if (minEstFees > outgoingMaxFeeMsat) { throw new Error('Estimated fees are too high') } diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js index 046115994a..0862fb3570 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -42,7 +42,14 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { if (pendingOrFailed.exists) return - const { invoice, wallet } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models }) + const { invoice, wallet } = await createInvoice(id, + { + msats, + description: 'SN: autowithdrawal', + expiry: 360, + direct: true // no need to wrap this one + }, { models, lnd }) + return await createWithdrawal(null, { invoice, maxFee: msatsToSats(maxFeeMsats) }, { me: { id }, models, lnd, walletId: wallet.id }) diff --git a/worker/territory.js b/worker/territory.js index d7a7f479f5..87c2e4cf5b 100644 --- a/worker/territory.js +++ b/worker/territory.js @@ -36,7 +36,7 @@ export async function territoryBilling ({ data: { subName }, boss, models }) { try { const { result } = await performPaidAction('TERRITORY_BILLING', - { name: subName }, { models, me: sub.user, lnd, forceFeeCredits: true }) + { name: subName }, { models, me: sub.user, lnd, forceInternal: true }) if (!result) { throw new Error('not enough fee credits to auto-renew territory') } diff --git a/worker/wallet.js b/worker/wallet.js index 44149ec318..cae4423860 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -7,7 +7,7 @@ import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush' import { INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' import { datePivot, sleep } from '@/lib/time' import retry from 'async-retry' -import { addWalletLog } from '@/api/resolvers/wallet' +import { addWalletLog } from '@/wallets/server' import { msatsToSats, numWithUnits } from '@/lib/format' import { paidActionPaid, paidActionForwarded, diff --git a/worker/weeklyPosts.js b/worker/weeklyPosts.js index ca96c73f44..5b764b7f58 100644 --- a/worker/weeklyPosts.js +++ b/worker/weeklyPosts.js @@ -6,7 +6,7 @@ import gql from 'graphql-tag' export async function autoPost ({ data: item, models, apollo, lnd, boss }) { return await performPaidAction('ITEM_CREATE', { ...item, subName: 'meta', userId: USER_ID.sn, apiKey: true }, - { models, me: { id: USER_ID.sn }, lnd, forceFeeCredits: true }) + { models, me: { id: USER_ID.sn }, lnd, forceInternal: true }) } export async function weeklyPost (args) { @@ -47,5 +47,5 @@ export async function payWeeklyPostBounty ({ data: { id }, models, apollo, lnd } await performPaidAction('ZAP', { id: winner.id, sats: item.bounty }, - { models, me: { id: USER_ID.sn }, lnd, forceFeeCredits: true }) + { models, me: { id: USER_ID.sn }, lnd, forceInternal: true }) }