diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 424198be4c..3dc304f006 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_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, INVOICE_EXPIRE_SECS } from '@/lib/constants' +import { parsePaymentRequest } from 'ln-service' import { Prisma } from '@prisma/client' import * as ITEM_CREATE from './itemCreate' import * as ITEM_UPDATE from './itemUpdate' @@ -14,8 +12,11 @@ import * as TERRITORY_BILLING from './territoryBilling' import * as TERRITORY_UNARCHIVE from './territoryUnarchive' import * as DONATE from './donate' import * as BOOST from './boost' -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 { SN_WALLET, createInvoice, createHodlInvoice, createFeeCreditInvoice } from 'wallets/server' export const paidActions = { ITEM_CREATE, @@ -28,65 +29,128 @@ export const paidActions = { TERRITORY_UPDATE, TERRITORY_BILLING, TERRITORY_UNARCHIVE, - DONATE + DONATE, + TRANSFER } +/** + * @typedef {import('@prisma/client').Invoice} InvoiceEntry + * @typedef {import('@prisma/client').User} User + * @typedef {import('@prisma/client').PrismaClient} PrismaClient + * @typedef {Omit} PrismaTransaction + * @typedef {import('@/wallets/server').InvoiceData} InvoiceData + * + * @typedef {Object} PaidActionContext + * @property {PrismaClient} [models] + * @property {PrismaClient | PrismaTransaction} [tx] + * @property {User} me + * @property {bigint} cost + * @property {string} description + * @property {boolean} [optimistic] + * @property {boolean} [forceFeeCredits] + * @property {boolean} [fallbackToSN] + * @property {boolean} [disableFeeCredit] + * @property {Object} lnd + * @property {*} actionId + * @property {InvoiceEntry} [retryForInvoice] - the id of the invoice to retry + */ + +/** + * Check if the user is allowed to perform a paid action + * @param {number} userId + * @param {PaidActionContext} context + * @param {bigint} [minSats] +*/ +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') + } +} + +/** + * + * @param {string} actionType + * @param {Object} args + * @param {Object} context + * @returns + */ export default async function performPaidAction (actionType, args, context) { try { - const { me, models, forceFeeCredits } = context - const paidAction = paidActions[actionType] + // TODO : this part is just to keep the current SN behavior + // for testing, should be removed for 5nov patch + const prioritizeFeeCredits = true + context.disableFeeCredit = context.disableFeeCredit ?? false + /// //// console.group('performPaidAction', actionType, args) - + const paidAction = paidActions[actionType] if (!paidAction) { throw new Error(`Invalid action type ${actionType}`) } - context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined - context.cost = await paidAction.getCost(args, context) + context.me = context.me ? await context.models.user.findUnique({ where: { id: context.me.id } }) : undefined + context.cost = context.cost ?? await paidAction.getCost(args, context) + if (context.cost < 0n) throw new Error('Cost cannot be negative') + context.sybilFeePercent = 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') + + context.description = context.me?.hideInvoiceDesc ? undefined : (context.description ?? await paidAction.describe(args, context)) + context.descriptionHash = context.descriptionHash ?? (paidAction.describeHash ? await paidAction.describeHash(args, context) : undefined) + context.fallbackToSN = context.fallbackToSN ?? true + + const { me, forceFeeCredits, cost, description, descriptionHash, sybilFeePercent, fallbackToSN, disableFeeCredit } = context + let canPerformOptimistically = paidAction.supportsOptimism + const canPerformPessimistically = paidAction.supportsPessimism ?? true + + if (!canPerformOptimistically && !canPerformPessimistically) { + throw new Error('Action is ambiguous. Does not support optimistic or pessimistic execution') + } if (!me) { if (!paidAction.anonable) { throw new Error('You must be logged in to perform this action') } - if (context.cost > 0) { + if (context.cost > 0n) { console.log('we are anon so can only perform pessimistic action that require payment') - return await performPessimisticAction(actionType, args, context) + canPerformOptimistically = false } } - const isRich = context.cost <= (context.me?.msats ?? 0) - if (isRich) { - try { - console.log('enough fee credits available, performing fee credit action') - return await performFeeCreditAction(actionType, args, context) - } catch (e) { - console.error('fee credit action failed', e) - - // if we fail with fee credits, but not because of insufficient funds, bail - if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { - throw e - } - } - } + const receiverUserId = await paidAction.invoiceablePeer?.(args, context) ?? SN_WALLET - // this is set if the worker executes a paid action in behalf of a user. - // in that case, only payment via fee credits is possible - // since there is no client to which we could send an invoice. - // example: automated territory billing - if (forceFeeCredits) { - throw new Error('forceFeeCredits is set, but user does not have enough fee credits') + // if forceFeeCredit or the action is free or prioritizeFeeCredits perform a fee credit action + if (me && !disableFeeCredit && ((forceFeeCredits || cost === 0n) || (prioritizeFeeCredits && (me?.msats ?? 0n) >= cost))) { + const invoiceData = await createFeeCreditInvoice(receiverUserId, { msats: cost, description }) + return await performFeeCreditAction(invoiceData, actionType, paidAction, args, context) } - // if we fail to do the action with fee credits, we should fall back to optimistic - if (paidAction.supportsOptimism) { - console.log('performing optimistic action') - return await performOptimisticAction(actionType, args, context) - } + const invoiceData = await (canPerformOptimistically ? createInvoice : createHodlInvoice)(receiverUserId, { + msats: cost, + description, + descriptionHash, + fallbackToSN, + sybilFeePercent + }, context) - console.error('action does not support optimism and fee credits failed, performing pessimistic action') - return await performPessimisticAction(actionType, args, context) + if (canPerformOptimistically) return await performOptimisticAction(invoiceData, actionType, paidAction, args, context) + return await performPessimisticAction(invoiceData, actionType, paidAction, args, context) } catch (e) { console.error('performPaidAction failed', e) throw e @@ -95,245 +159,271 @@ export default async function performPaidAction (actionType, args, context) { } } -async function performFeeCreditAction (actionType, args, context) { - const { me, models, cost } = context - const action = paidActions[actionType] +/** + * + * @param {InvoiceEntry} invoiceEntry + * @param {Object} paidAction + * @param {*} args + * @param {PaidActionContext} context + * @returns + */ +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) + } +} + +/** + * + * @param {InvoiceData} invoiceData + * @param {string} actionType + * @param {Object} paidAction + * @param {*} args + * @param {PaidActionContext} context + * @returns + */ +async function performFeeCreditAction (invoiceData, actionType, paidAction, args, context) { + const { me, models, retryForInvoice } = context + const cost = invoiceData.msats + if (cost > (me?.msats ?? 0)) { + throw new Error('forceFeeCredits is set, but user does not have enough fee credits ' + me?.msats + ' < ' + cost) + } - const result = await models.$transaction(async tx => { + const run = async tx => { context.tx = tx + await checkUser(me?.id ?? USER_ID.anon, context, cost) - await tx.user.update({ - where: { - id: me?.id ?? USER_ID.anon - }, - data: { - msats: { - decrement: cost + try { + console.log('enough fee credits available, performing fee credit action') + await tx.user.update({ + where: { + id: me?.id ?? USER_ID.anon + }, + data: { + msats: { + decrement: cost + } } - } - }) + }) + console.log('Paid action with fee credits') - const result = await action.perform(args, context) - await action.onPaid?.(result, context) + const invoiceEntry = await createDbInvoice(invoiceData, { + actionType, + actionId: context.actionId, + optimistic: true, + args + }, context) + const result = await performAction(invoiceEntry, paidAction, args, context) - return { - result, - paymentMethod: 'FEE_CREDIT' + await paidAction.onPaid?.({ ...result, invoice: invoiceEntry }, context) + + return { + result, + paymentMethod: 'FEE_CREDIT' + } + } catch (e) { + console.error('fee credit 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.ReadCommitted }) // 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 = await createLightningInvoice(actionType, args, context) - - return await models.$transaction(async tx => { +/** + * @param {InvoiceData} invoiceData* + * @param {string} actionType + * @param {Object} paidAction + * @param {Object} args + * @param {PaidActionContext} context + */ +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), + invoice: invoiceEntry, + result: await performAction(invoiceEntry, paidAction, args, context), paymentMethod: 'OPTIMISTIC' } - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) -} - -async function performPessimisticAction (actionType, args, context) { - const action = paidActions[actionType] - - if (!action.supportsPessimism) { - throw new Error(`This action ${actionType} does not support pessimistic invoicing`) } + if (context.tx) return await performInvoicedAction(context.tx) + return await models.$transaction(performInvoicedAction, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) +} - // just create the invoice and complete action when it's paid - const invoiceArgs = await createLightningInvoice(actionType, args, context) - return { - invoice: await createDbInvoice(actionType, args, context, invoiceArgs), - paymentMethod: 'PESSIMISTIC' +/** + * @param {InvoiceData} invoiceData + * + * @param {string} actionType + * @param {Object} paidAction + * @param {Object} args + * @param {PaidActionContext} context + */ +async function performPessimisticAction (invoiceData, actionType, paidAction, args, context) { + const { models, actionId, me } = context + const performInvoicedAction = async tx => { + context.tx = tx + context.optimistic = false + await checkUser(me?.id ?? USER_ID.anon, context) + + const invoiceEntry = await createDbInvoice(invoiceData, { + actionType, + actionId, + optimistic: false, + args + }, context) + return { + invoice: invoiceEntry, + paymentMethod: 'PESSIMISTIC' + } } + if (context.tx) return await performInvoicedAction(context.tx) + return await models.$transaction(performInvoicedAction, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) } -export async function retryPaidAction (actionType, args, context) { - const { models, me } = context - const { invoice: failedInvoice } = args +/** + * + * @param {string} actionType + * @param {Object} args + * @param {InvoiceEntry} args.invoice + * @param {boolean} args.forceFeeCredits + * @param {PaidActionContext} context + * @returns + */ +export async function retryPaidAction (actionType, { invoice, forceFeeCredits }, context) { + const { models } = context + const failedInvoice = invoice - console.log('retryPaidAction', actionType, args) + console.log('retryPaidAction', actionType, invoice) - const action = paidActions[actionType] - if (!action) { + const paidAction = paidActions[actionType] + if (!paidAction) { throw new Error(`retryPaidAction - invalid action type ${actionType}`) } - if (!me) { - throw new Error(`retryPaidAction - must be logged in ${actionType}`) - } - - if (!action.supportsOptimism) { - 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) + const { msatsRequested, actionId, actionArgs } = failedInvoice + context.cost = Number(msatsRequested) context.actionId = actionId - const invoiceArgs = await createSNInvoice(actionType, args, context) + context.retryForInvoice = failedInvoice + context.forceFeeCredits = forceFeeCredits + + const supportRetrying = paidAction.retry return await models.$transaction(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) - - return { - result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context), - invoice, - paymentMethod: 'OPTIMISTIC' + 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' + } + }) } + return await performPaidAction(actionType, actionArgs, context) }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) } -const INVOICE_EXPIRE_SECS = 600 -const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 - -export async function createLightningInvoice (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) +/** + * Track an invoice, optionally associate it to some action data + * @param {InvoiceData} invoiceData + * @param {ActionData} actionData + * @param {Object} context + * @param {Object} [context.me] + * @param {import('@/api/paidAction').PrismaClient} [context.models] + * @param {import('@/api/paidAction').PrismaTransaction} [context.tx] + * @returns + */ +export async function createDbInvoice (invoiceData, actionData, { me, models, tx }) { + const { invoice: servedBolt11, wallet, maxFee, preimage, innerInvoice: bolt11, isWrapped, isHodl, isFeeCredit } = invoiceData + const { actionId, actionType, optimistic: isOptimisticAction, args } = actionData - // count pending invoices and bail if we're over the limit - const pendingInvoices = await models.invoice.count({ - where: { - userId: me?.id ?? USER_ID.anon, - 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 (userId) { - try { - 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 { invoice: wrappedInvoice, maxFee } = await wrapInvoice( - bolt11, { msats: cost, description }, { lnd }) + const db = tx ?? models - return { - bolt11, - wrappedBolt11: wrappedInvoice.request, - wallet, - maxFee - } - } catch (e) { - console.error('failed to create stacker invoice, falling back to SN invoice', e) + let servedInvoice + if (invoiceData.isFeeCredit) { + servedInvoice = { + id: invoiceData.invoice, + mtokens: invoiceData.msats, + expires_at: Math.floor(Date.now() / 1000) + INVOICE_EXPIRE_SECS } + } else { + servedInvoice = await parsePaymentRequest({ request: servedBolt11 }) } + const expiresAt = new Date(servedInvoice.expires_at) - return await createSNInvoice(actionType, args, context) -} - -// 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 (cost < 1000n) { - // sanity check - throw new Error('The cost of the action must be at least 1 sat') + // sanity checks + if (servedInvoice.mtokens < 1000n) { + throw new Error('The amount must be at least 1 sat') } - - 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 } -} - -async function createDbInvoice (actionType, args, context, - { bolt11, wrappedBolt11, preimage, wallet, maxFee }) { - const { me, models, tx, cost, optimistic, actionId } = context - const db = tx ?? models - - if (cost < 1000n) { - // sanity check - throw new Error('The cost of the action 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 servedBolt11 = wrappedBolt11 ?? bolt11 - const servedInvoice = parsePaymentRequest({ request: servedBolt11 }) - const expiresAt = new Date(servedInvoice.expires_at) - - const invoiceData = { + 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: isFeeCredit ? 'PAID' : (isWrapped ? 'PENDING_HELD' : isOptimisticAction ? 'PENDING' : 'PENDING_HELD'), + actionOptimistic: isOptimisticAction, actionArgs: args, expiresAt, - actionId + actionId, + desc: invoiceData.description ?? servedInvoice.description } - let invoice - if (wrappedBolt11) { - invoice = (await db.invoiceForward.create({ + let invoiceEntry + if (isWrapped) { + invoiceEntry = (await db.invoiceForward.create({ include: { invoice: true }, data: { bolt11, - maxFeeMsats: maxFee, + maxFeeMsats: toPositiveNumber(maxFee), invoice: { - create: invoiceData + create: invoiceEntryData }, wallet: { connect: { @@ -343,21 +433,22 @@ async function createDbInvoice (actionType, args, context, } })).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) + // @ts-ignore + invoiceEntry.hmac = createHmac(invoiceEntry.hash) - return invoice + return invoiceEntry } diff --git a/api/paidAction/transfer.js b/api/paidAction/transfer.js new file mode 100644 index 0000000000..b6a6069084 --- /dev/null +++ b/api/paidAction/transfer.js @@ -0,0 +1,53 @@ +import { satsToMsats } from '@/lib/format' +import { notifyDeposit } from '@/lib/webPush' +export const anonable = true +export const supportsPessimism = true +export const supportsOptimism = false + +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/resolvers/item.js b/api/resolvers/item.js index 06ddec3c48..18ba51adb3 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 { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' +import { verifyHmac } from '@/lib/crypto' function commentsOrderByClause (me, models, sort) { if (sort === 'recent') { @@ -1351,10 +1351,13 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. const adminEdit = ADMIN_ITEMS.includes(old.id) && SN_ADMIN_IDS.includes(meId) // anybody can edit with valid hash+hmac let hmacEdit = false - if (old.invoice?.hash && hash && hmac) { - hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac) + try { + if (old.invoice?.hash && hash && hmac) { + hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac) + } + } catch (e) { + throw new GqlAuthorizationError(e.message) } - // ownership permission check if (!authorEdit && !adminEdit && !hmacEdit) { throw new GqlInputError('item does not belong to you') diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 20b6ceacf8..379be413ee 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -228,6 +228,17 @@ 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 "actionType" = 'TRANSFER' + AND created_at < $2 + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) } if (meFull.noteWithdrawals) { diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js index 2332790384..36c841cc7b 100644 --- a/api/resolvers/paidAction.js +++ b/api/resolvers/paidAction.js @@ -17,6 +17,8 @@ function paidActionType (actionType) { return 'SubPaidAction' case 'DONATE': return 'DonatePaidAction' + case 'TRANSFER': + return 'TransferPaidAction' case 'POLL_VOTE': return 'PollVotePaidAction' default: diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index ee1677aa5f..92bcdd44c6 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 { createHmac, verifyHmac } from '@/lib/crypto' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { SELECT, itemQueryWithMeta } from './item' @@ -23,6 +23,7 @@ import { generateResolverName, generateTypeDefName } from '@/lib/wallet' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' +import { addWalletLog } from '@/wallets/server' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') @@ -117,19 +118,6 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) { return wdrwl } -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) - if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) { - throw new GqlAuthorizationError('bad hmac') - } - return true -} - const resolvers = { Query: { invoice: getInvoice, @@ -465,7 +453,11 @@ const resolvers = { createWithdrawl: createWithdrawal, sendToLnAddr, cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => { - verifyHmac(hash, hmac) + try { + verifyHmac(hash, hmac) + } catch (err) { + throw new GqlAuthorizationError(err.message) + } await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) return await models.invoice.findFirst({ where: { hash } }) }, @@ -618,14 +610,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, priorityOnly }, { me, models }) { if (!me) { diff --git a/api/typeDefs/paidAction.js b/api/typeDefs/paidAction.js index 1a6c7a59cb..97872def05 100644 --- a/api/typeDefs/paidAction.js +++ b/api/typeDefs/paidAction.js @@ -51,4 +51,10 @@ type DonatePaidAction implements PaidAction { paymentMethod: PaymentMethod! } +type TransferPaidAction implements PaidAction { + result: TransferResult + invoice: Invoice + paymentMethod: PaymentMethod! +} + ` diff --git a/api/typeDefs/rewards.js b/api/typeDefs/rewards.js index 6a10448665..83e860fe5b 100644 --- a/api/typeDefs/rewards.js +++ b/api/typeDefs/rewards.js @@ -14,6 +14,10 @@ export default gql` sats: Int! } + type TransferResult { + sats: Int! + } + type Rewards { total: Int! time: Date! diff --git a/lib/apollo.js b/lib/apollo.js index 2bde508fb2..0fda4022d6 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 ad32723601..0ab589151d 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -3,6 +3,10 @@ export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs'] export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs') +/** + * @typedef {import('@prisma/client').InvoiceActionState} InvoiceActionState + * @type {InvoiceActionState[]} + */ export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING'] export const NOFOLLOW_LIMIT = 1000 export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener' @@ -182,3 +186,10 @@ 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 + +export const INVOICE_EXPIRE_SECS = 600 +export const MAX_PENDING_INVOICES_PER_WALLET = 25 +export const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 +// 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 diff --git a/lib/crypto.js b/lib/crypto.js index d1812cbb90..3af9cd9cb2 100644 --- a/lib/crypto.js +++ b/lib/crypto.js @@ -1,4 +1,5 @@ -import { createHash } from 'node:crypto' +import crypto, { createHash } from 'node:crypto' +import { timingSafeEqual } from 'crypto' export function hashEmail ({ email, @@ -7,3 +8,15 @@ 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) + if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) { + throw new Error('bad hmac') + } + return true +} diff --git a/lib/validate.js b/lib/validate.js index f8fd24d973..5e3161d53d 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -829,18 +829,78 @@ export const lud18PayerDataSchema = (k1) => object({ // 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') } - const n = Number(x) - if (isNumber(n)) { - if (x < min || x > max) { + if (typeof x === 'bigint') { + if (x < BigInt(min) || x > BigInt(max)) { throw new Error(`value ${x} must be between ${min} and ${max}`) } - return n + 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`) } -export const toPositiveNumber = (x) => toNumber(x, 0) +/** + * @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 b153a2eeaf..b18e2e1121 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -1,15 +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 { ssValidate, lud18PayerDataSchema } from '@/lib/validate' +import { LNURLP_COMMENT_MAX_LENGTH } from '@/lib/constants' +import { ssValidate, 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 } }) if (!user) { @@ -71,27 +69,19 @@ 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 - }) - - 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 } - ) + descriptionHash, + comment: comment || '', + targetUserId: user.id + }, { models, lnd, me: user, disableFeeCredit: true }) + 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..5718c3e7f2 --- /dev/null +++ b/prisma/migrations/20241029160827_transfer_paid_action/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "InvoiceActionType" ADD VALUE 'TRANSFER'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee6e180497..99a3a8ec3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -822,6 +822,7 @@ enum InvoiceActionType { DOWN_ZAP BOOST DONATE + TRANSFER POLL_VOTE TERRITORY_CREATE TERRITORY_UPDATE diff --git a/wallets/server.js b/wallets/server.js index 8f8e8f355a..286fee2356 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -4,113 +4,403 @@ import * as lnAddr from 'wallets/lightning-address/server' import * as lnbits from 'wallets/lnbits/server' import * as nwc from 'wallets/nwc/server' import * as phoenixd from 'wallets/phoenixd/server' -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 { withTimeout } from '@/lib/time' -export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd] - -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' } - ] - }) +import { parsePaymentRequest, createInvoice as lndCreateInvoice, createHodlInvoice as lndCreateHodlInvoice } from 'ln-service' +import { PAID_ACTION_TERMINAL_STATES, INVOICE_EXPIRE_SECS, MAX_PENDING_INVOICES_PER_WALLET, ZAP_SYBIL_FEE_PERCENT } from '@/lib/constants' +import { withTimeout, datePivot } from '@/lib/time' +import wrapInvoice from 'wallets/wrap' +import assert from 'assert' +import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate' - msats = toPositiveNumber(msats) +const walletDefs = [lnd, cln, lnAddr, lnbits, nwc, phoenixd] +export default walletDefs +export const SN_WALLET = null +export const FEE_CREDIT_WALLET = 'fee-credit' - for (const wallet of wallets) { - const w = walletDefs.find(w => w.walletType === wallet.type) - try { - const { walletType, walletField, createInvoice } = w - - const walletFull = await models.wallet.findFirst({ - where: { - userId, - type: walletType - }, +/** + * @typedef {Object} InvoiceData + * @property {string} invoice - The bolt11 invoice + * @property {string} [preimage] - The preimage (secret) of the invoice. + * @property {Object} wallet - The wallet that created the invoice (SN_WALLET (null) if from Stacker News central wallet). + * @property {boolean} [isWrapped] - Indicates if the invoice is wrapped. + * @property {boolean} [isHodl] - Indicates if the invoice is a hodl invoice. + * @property {string} [innerInvoice] - The bolt11 invoice that was wrapped if isWrapped is true otherwise undefined + * @property {bigint} [maxFee] - The maximum fee for the wrapped invoice. + * @property {boolean} [isFeeCredit] - Indicates if it is a fee credit invoice + * @property {bigint} [msats] - The cost of the fee credit invoice + * @property {string} [description] - The description of the invoice. + */ + +/** + * @typedef {Object} ActionData + * @property {number} actionId + * @property {string} actionType + * @property {boolean} optimistic - Indicates if the action is optimistic. + * @property {*} args + */ + +/** + * + * @param {Object} param0 + * @param {*} param0.wallet + * @param {*} param0.level + * @param {*} param0.message + * + * @param {Object} context + * @param {import('@/api/paidAction').PrismaClient} context.models + */ +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) + } +} + +/** + * Create a fee credit invoice. + * Used only for internal transactions. + * @param {number | undefined} userId - the id of the user for whom the invoice is being created (or SN_WALLET if for stacker news). + * @param {Object} args + * @param {bigint} args.msats - The amount in millisatoshis. + * @returns {Promise} A promise that resolves to an object containing the invoice details. + */ +export async function createFeeCreditInvoice (userId, { msats, description }) { + msats = toPositiveBigInt(msats) + // TODO generate better random ID + const randomId = 'cc' + msats + (userId ?? '00') + Math.random().toString(36).substring(2, 15) + return { + invoice: randomId, + wallet: FEE_CREDIT_WALLET, + isWrapped: false, + isHodl: false, + isFeeCredit: true, + msats, + description + } +} + +/** + * Create an hodl invoice for the given user. + * Same as calling createInvoice(..., { hodl: true, ... }) + * Unless specified otherwise: + * - If p2p is possible, a wrapped invoice will be returned. + * - If p2p is not possible, an invoice for the custodial SN wallet will be returned. + * + * @param {number | undefined} userId - the ID of the user for whom the invoice is being created (or SN_WALLET if the invoice is paid to stacker news). + * @param {bigint} args.msats - The amount in millisatoshis. + * @param {string} args.description - The description of the invoice. + * @param {string} [args.descriptionHash] - The hash of the description. + * @param {number} [args.expiry=INVOICE_EXPIRE_SECS] - The expiry time of the invoice in seconds. + * @param {bigint} [args.sybilFeePercent] - The sybil fee to use when needed (default to ZAP_SYBIL_FEE_MULT if undefined). + * @param {boolean} [args.fallbackToSN=true] - fallback to use stacker news central wallet if no other user wallet is available. + * @param {boolean} [args.direct=false] - if true, it will return an unwrapped invoice from attached wallets (unless hodl=true). + * @param {Object} context + * @param {Object} context.models + * @param {Object} context.lnd + * @param {Object} [context.useWallet] - Specify which wallet to use, if undefined the best wallet will be used. + * @returns {Promise} A promise that resolves to an object containing the invoice details. + * @returns + */ +export async function createHodlInvoice ( + userId, + { + msats, + description, + descriptionHash, + expiry = INVOICE_EXPIRE_SECS, + sybilFeePercent, + fallbackToSN = true, + direct = false + }, + { models, lnd, useWallet } +) { + return createInvoice(userId, { + msats, + description, + descriptionHash, + expiry, + sybilFeePercent, + fallbackToSN, + hodl: true, + direct + }, { models, lnd, useWallet }) +} + +/** + * Create an invoice for the given user. + * Unless specified otherwise: + * - If p2p is possible, a wrapped invoice will be returned. + * - If p2p is not possible, an invoice for the custodial SN wallet will be returned. + * + * @param {number} userId - the ID of the user for whom the invoice is being created (or SN_WALLET if the invoice is paid to stacker news). + * @param {Object} args + * @param {bigint} args.msats - The amount in millisatoshis. + * @param {string} args.description - The description of the invoice. + * @param {string} [args.descriptionHash] - The hash of the description. + * @param {number} [args.expiry=INVOICE_EXPIRE_SECS] - The expiry time of the invoice in seconds. + * @param {bigint} [args.sybilFeePercent] - The sybil fee to use when needed (default to ZAP_SYBIL_FEE_MULT if undefined). + * @param {boolean} [args.fallbackToSN=true] - fallback to use stacker news central wallet if no other user wallet is available. + * @param {boolean} [args.hodl=false] - if true, the returned invoice will be always an hodl invoice. + * @param {boolean} [args.direct=false] - if true, it will return an unwrapped invoice from attached wallets (unless hodl=true). + * @param {Object} context + * @param {Object} context.models + * @param {Object} context.lnd + * @param {Object} [context.useWallet] - Specify which wallet to use, if undefined the best wallet will be used. + * @returns {Promise} A promise that resolves to an object containing the invoice details. + * @private + */ +export async function createInvoice ( + userId, + { + msats, + description, + descriptionHash, + expiry = INVOICE_EXPIRE_SECS, + sybilFeePercent, + fallbackToSN = true, + hodl = false, + direct = false + }, + { 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 (userId === SN_WALLET) { + console.log('Invoice SN wallet') + // we are creating an invoice paid to stacker news + const invoice = await createSNInvoice(description, descriptionHash, msats, expiry, hodl, { lnd }) + return { + invoice: invoice.invoice, + preimage: invoice.preimage, + wallet: SN_WALLET, + isWrapped: false, + isHodl: hodl, + msats, + description + } + } + + // we are creating an invoice paid to an user + const wallets = + useWallet + ? [useWallet] // if a wallet is specified, use it + : await models.wallet.findMany({ + where: { userId, enabled: true }, include: { - [walletField]: true - } + user: true + }, + orderBy: [ + { priority: 'asc' }, + // use id as tie breaker (older wallet first) + { id: 'asc' } + ] }) - if (!walletFull || !walletFull[walletField]) { - throw new Error(`no ${walletType} wallet found`) + if (fallbackToSN && useWallet !== SN_WALLET) { + // add the SN wallet as last wallet, so if everything else fails + // we create a custodial invoice + wallets.push(SN_WALLET) + } + + for (const wallet of wallets) { + if (wallet === SN_WALLET) { + // We are invoicing the user through the custodial SN wallet + // so we can create (an un-wrapped) hodl or regular invoice + console.log('Use SN wallet') + const invoice = await createSNInvoice(description, descriptionHash, msats, expiry, hodl, { lnd }) + return { + invoice: invoice.invoice, + preimage: invoice.preimage, + wallet: SN_WALLET, + isWrapped: false, + isHodl: !!hodl, + msats, + description } + } else { + // We are invoicing an attached wallet (p2p invoice) + console.log('Use wallet', wallet) + const w = walletDefs.find(w => w.walletType === wallet.type) + try { + const { walletType, walletField, createInvoice } = w + const walletFull = await models.wallet.findFirst({ + where: { + userId, + type: walletType + }, + include: { + [walletField]: true + } + }) + await checkWallet(models, walletFull, walletField, walletType) - // check for pending withdrawals - const pendingWithdrawals = await models.withdrawl.count({ - where: { - walletId: walletFull.id, - status: null - } - }) + 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') - // and pending forwards - const pendingForwards = await models.invoiceForward.count({ - where: { - walletId: walletFull.id, - invoice: { - actionState: { - notIn: PAID_ACTION_TERMINAL_STATES - } - } - } - }) + const invoice = await withTimeout( + createInvoice({ + msats: toPositiveNumber(innerMsats), + description: wallet.user.hideInvoiceDesc ? undefined : description, + descriptionHash, + expiry + }, walletFull[walletField]), 10_000) + await checkInvoice(models, wallet, invoice, innerMsats) - console.log('pending invoices', pendingWithdrawals + pendingForwards) - if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) { - throw new Error('wallet has too many pending invoices') - } + 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) - const invoice = await withTimeout( - createInvoice({ - 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') - } - if (BigInt(msats) - BigInt(bolt11.mtokens) >= 1000n) { - throw new Error(`invoice has a different satoshi amount ${bolt11.mtokens} !== ${msats}`) - } + return { + innerInvoice: invoice, + invoice: wrappedInvoice.request, + wallet, + maxFee, + isWrapped: true, + isHodl: true, + msats, + description + } + } else { + // return a regular invoice straight from the attached wallet + const invoice = await withTimeout( + createInvoice({ + msats, + description: wallet.user.hideInvoiceDesc ? undefined : description, + descriptionHash, + expiry + }, walletFull[walletField]), 10_000) + await checkInvoice(models, wallet, invoice, msats) + return { + invoice, + wallet, + isWrapped: false, + isHodl: false, + msats, + description + } + } + } catch (error) { + console.error(error) await addWalletLog({ wallet, - level: 'INFO', - message: `wallet does not support msats so we floored ${msats} msats to nearest sat ${BigInt(bolt11.mtokens)} msats` + level: 'ERROR', + message: `creating invoice for ${description ?? ''} failed: ` + error }, { models }) } - - return { invoice, wallet } - } catch (error) { - console.error(error) - await addWalletLog({ - wallet, - level: 'ERROR', - message: `creating invoice for ${description ?? ''} failed: ` + error - }, { models }) } } throw new Error('no wallet available') } + +/** + * + * @param {string} description + * @param {string | undefined} descriptionHash + * @param {bigint} cost + * @param {number} expiry + * @param {boolean} isHodl + * @param {*} param4 + * @returns + */ +async function createSNInvoice (description, descriptionHash, cost, expiry, 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 + } +} + +/** + * Check if an invoice is good + * @param {*} models + * @param {*} wallet + * @param {string} invoice + * @param {bigint} msats + */ +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 ae63a6ed81..acc055a85f 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,20 +10,32 @@ 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 +/** + * 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) { + if (!zapSybilFeePercent) { + console.log('Use default zapSybilFeePercent', ZAP_SYBIL_FEE_PERCENT) + zapSybilFeePercent = ZAP_SYBIL_FEE_PERCENT } -*/ -export default async function wrapInvoice (bolt11, { msats, description, descriptionHash }, { lnd }) { try { console.group('wrapInvoice', description) @@ -40,7 +53,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 +66,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') @@ -156,8 +172,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..3df2346706 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -42,7 +42,15 @@ 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, + fallbackToSN: false, // if the user doesn't have an attached wallet, there is nothing to withdraw to + 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/wallet.js b/worker/wallet.js index 57156b9dab..890d40ea7f 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.js' 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,