a46f81f1e1
* Use same naming scheme between ln containers and env vars * Add router_lnd container * Only open channels to router_lnd * Use 1sat base fee and 0ppm fee rate * Add script to test routing * Also fund router_lnd wallet * Receiver fallbacks * Rename to predecessorId * Remove useless wallet table join * Missing renaming to predecessor * Fix payment stuck on sender error We want to await the invoice poll promise so we can check for receiver errors, but in case of sender errors, the promise will never settle. * Don't log failed forwards as sender errors * fix check for receiver error --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
477 lines
15 KiB
JavaScript
477 lines
15 KiB
JavaScript
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
|
|
import { datePivot } from '@/lib/time'
|
|
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
|
import { createHmac } from '@/api/resolvers/wallet'
|
|
import { Prisma } from '@prisma/client'
|
|
import { createWrappedInvoice, createInvoice as createUserInvoice } from '@/wallets/server'
|
|
import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert'
|
|
|
|
import * as ITEM_CREATE from './itemCreate'
|
|
import * as ITEM_UPDATE from './itemUpdate'
|
|
import * as ZAP from './zap'
|
|
import * as DOWN_ZAP from './downZap'
|
|
import * as POLL_VOTE from './pollVote'
|
|
import * as TERRITORY_CREATE from './territoryCreate'
|
|
import * as TERRITORY_UPDATE from './territoryUpdate'
|
|
import * as TERRITORY_BILLING from './territoryBilling'
|
|
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
|
|
import * as DONATE from './donate'
|
|
import * as BOOST from './boost'
|
|
import * as RECEIVE from './receive'
|
|
import * as INVITE_GIFT from './inviteGift'
|
|
|
|
export const paidActions = {
|
|
ITEM_CREATE,
|
|
ITEM_UPDATE,
|
|
ZAP,
|
|
DOWN_ZAP,
|
|
BOOST,
|
|
POLL_VOTE,
|
|
TERRITORY_CREATE,
|
|
TERRITORY_UPDATE,
|
|
TERRITORY_BILLING,
|
|
TERRITORY_UNARCHIVE,
|
|
DONATE,
|
|
RECEIVE,
|
|
INVITE_GIFT
|
|
}
|
|
|
|
export default async function performPaidAction (actionType, args, incomingContext) {
|
|
try {
|
|
const { me, models, forcePaymentMethod } = incomingContext
|
|
const paidAction = paidActions[actionType]
|
|
|
|
console.group('performPaidAction', actionType, args)
|
|
|
|
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')
|
|
}
|
|
|
|
// treat context as immutable
|
|
const contextWithMe = {
|
|
...incomingContext,
|
|
me: me ? await models.user.findUnique({ where: { id: parseInt(me.id) } }) : undefined
|
|
}
|
|
const context = {
|
|
...contextWithMe,
|
|
cost: await paidAction.getCost(args, contextWithMe),
|
|
sybilFeePercent: await paidAction.getSybilFeePercent?.(args, contextWithMe)
|
|
}
|
|
|
|
// special case for zero cost actions
|
|
if (context.cost === 0n) {
|
|
console.log('performing zero cost action')
|
|
return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod: 'ZERO_COST' })
|
|
}
|
|
|
|
for (const paymentMethod of paidAction.paymentMethods) {
|
|
console.log(`considering payment method ${paymentMethod}`)
|
|
const contextWithPaymentMethod = { ...context, paymentMethod }
|
|
|
|
if (forcePaymentMethod &&
|
|
paymentMethod !== forcePaymentMethod) {
|
|
console.log('skipping payment method', paymentMethod, 'because forcePaymentMethod is set to', forcePaymentMethod)
|
|
continue
|
|
}
|
|
|
|
// payment methods that anonymous users can use
|
|
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) {
|
|
try {
|
|
return await performP2PAction(actionType, args, contextWithPaymentMethod)
|
|
} catch (e) {
|
|
if (e instanceof NonInvoiceablePeerError) {
|
|
console.log('peer cannot be invoiced, skipping')
|
|
continue
|
|
}
|
|
console.error(`${paymentMethod} action failed`, e)
|
|
throw e
|
|
}
|
|
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) {
|
|
return await beginPessimisticAction(actionType, args, contextWithPaymentMethod)
|
|
}
|
|
|
|
// additional payment methods that logged in users can use
|
|
if (me) {
|
|
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) {
|
|
try {
|
|
return await performNoInvoiceAction(actionType, args, contextWithPaymentMethod)
|
|
} 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 \\"msats_positive\\"') &&
|
|
!e.message.includes('\\"users\\" violates check constraint \\"mcredits_positive\\"')) {
|
|
throw e
|
|
}
|
|
}
|
|
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) {
|
|
return await performOptimisticAction(actionType, args, contextWithPaymentMethod)
|
|
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT) {
|
|
try {
|
|
return await performDirectAction(actionType, args, contextWithPaymentMethod)
|
|
} catch (e) {
|
|
if (e instanceof NonInvoiceablePeerError) {
|
|
console.log('peer cannot be invoiced, skipping')
|
|
continue
|
|
}
|
|
console.error(`${paymentMethod} action failed`, e)
|
|
throw e
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error('No working payment method found')
|
|
} catch (e) {
|
|
console.error('performPaidAction failed', e)
|
|
throw e
|
|
} finally {
|
|
console.groupEnd()
|
|
}
|
|
}
|
|
|
|
async function performNoInvoiceAction (actionType, args, incomingContext) {
|
|
const { me, models, cost, paymentMethod } = incomingContext
|
|
const action = paidActions[actionType]
|
|
|
|
const result = await models.$transaction(async tx => {
|
|
const context = { ...incomingContext, tx }
|
|
|
|
if (paymentMethod === 'FEE_CREDIT') {
|
|
await tx.user.update({
|
|
where: {
|
|
id: me?.id ?? USER_ID.anon
|
|
},
|
|
data: { msats: { decrement: cost } }
|
|
})
|
|
}
|
|
|
|
const result = await action.perform(args, context)
|
|
await action.onPaid?.(result, context)
|
|
|
|
return {
|
|
result,
|
|
paymentMethod
|
|
}
|
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
|
|
|
// run non critical side effects in the background
|
|
// after the transaction has been committed
|
|
action.nonCriticalSideEffects?.(result.result, incomingContext).catch(console.error)
|
|
return result
|
|
}
|
|
|
|
async function performOptimisticAction (actionType, args, incomingContext) {
|
|
const { models, invoiceArgs: incomingInvoiceArgs } = incomingContext
|
|
const action = paidActions[actionType]
|
|
|
|
const optimisticContext = { ...incomingContext, optimistic: true }
|
|
const invoiceArgs = incomingInvoiceArgs ?? await createSNInvoice(actionType, args, optimisticContext)
|
|
|
|
return await models.$transaction(async tx => {
|
|
const context = { ...optimisticContext, tx, invoiceArgs }
|
|
|
|
const invoice = await createDbInvoice(actionType, args, context)
|
|
|
|
return {
|
|
invoice,
|
|
result: await action.perform?.({ invoiceId: invoice.id, ...args }, context),
|
|
paymentMethod: 'OPTIMISTIC'
|
|
}
|
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
|
}
|
|
|
|
async function beginPessimisticAction (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'
|
|
}
|
|
}
|
|
|
|
async function performP2PAction (actionType, args, incomingContext) {
|
|
// if the action has an invoiceable peer, we'll create a peer invoice
|
|
// wrap it, and return the wrapped invoice
|
|
const { cost, sybilFeePercent, models, lnd, me } = incomingContext
|
|
if (!sybilFeePercent) {
|
|
throw new Error('sybil fee percent is not set for an invoiceable peer action')
|
|
}
|
|
|
|
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
|
|
if (!userId) {
|
|
throw new NonInvoiceablePeerError()
|
|
}
|
|
|
|
let context
|
|
try {
|
|
await assertBelowMaxPendingInvoices(incomingContext)
|
|
|
|
const description = await paidActions[actionType].describe(args, incomingContext)
|
|
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
|
|
msats: cost,
|
|
feePercent: sybilFeePercent,
|
|
description,
|
|
expiry: INVOICE_EXPIRE_SECS
|
|
}, { models, me, lnd })
|
|
|
|
context = {
|
|
...incomingContext,
|
|
invoiceArgs: {
|
|
bolt11: invoice,
|
|
wrappedBolt11: wrappedInvoice,
|
|
wallet,
|
|
maxFee
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('failed to create wrapped invoice', e)
|
|
throw new NonInvoiceablePeerError()
|
|
}
|
|
|
|
return me
|
|
? await performOptimisticAction(actionType, args, context)
|
|
: await beginPessimisticAction(actionType, args, context)
|
|
}
|
|
|
|
// we don't need to use the module for perform-ing outside actions
|
|
// because we can't track the state of outside invoices we aren't paid/paying
|
|
async function performDirectAction (actionType, args, incomingContext) {
|
|
const { models, lnd, cost } = incomingContext
|
|
const { comment, lud18Data, noteStr, description: actionDescription } = args
|
|
|
|
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
|
|
if (!userId) {
|
|
throw new NonInvoiceablePeerError()
|
|
}
|
|
|
|
let invoiceObject
|
|
|
|
try {
|
|
await assertBelowMaxPendingDirectPayments(userId, incomingContext)
|
|
|
|
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
|
|
invoiceObject = await createUserInvoice(userId, {
|
|
msats: cost,
|
|
description,
|
|
expiry: INVOICE_EXPIRE_SECS
|
|
}, { models, lnd })
|
|
} catch (e) {
|
|
console.error('failed to create outside invoice', e)
|
|
throw new NonInvoiceablePeerError()
|
|
}
|
|
|
|
const { invoice, wallet } = invoiceObject
|
|
const hash = parsePaymentRequest({ request: invoice }).id
|
|
|
|
const payment = await models.directPayment.create({
|
|
data: {
|
|
comment,
|
|
lud18Data,
|
|
desc: noteStr,
|
|
bolt11: invoice,
|
|
msats: cost,
|
|
hash,
|
|
walletId: wallet.id,
|
|
receiverId: userId
|
|
}
|
|
})
|
|
|
|
return {
|
|
invoice: payment,
|
|
paymentMethod: 'DIRECT'
|
|
}
|
|
}
|
|
|
|
export async function retryPaidAction (actionType, args, incomingContext) {
|
|
const { models, me } = incomingContext
|
|
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 (!failedInvoice) {
|
|
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
|
|
}
|
|
|
|
const { msatsRequested, actionId, actionArgs, actionOptimistic } = failedInvoice
|
|
const retryContext = {
|
|
...incomingContext,
|
|
optimistic: actionOptimistic,
|
|
me: await models.user.findUnique({ where: { id: parseInt(me.id) } }),
|
|
cost: BigInt(msatsRequested),
|
|
actionId,
|
|
predecessorId: failedInvoice.id
|
|
}
|
|
|
|
let invoiceArgs
|
|
const invoiceForward = await models.invoiceForward.findUnique({
|
|
where: {
|
|
invoiceId: failedInvoice.id
|
|
},
|
|
include: {
|
|
wallet: true
|
|
}
|
|
})
|
|
|
|
if (invoiceForward) {
|
|
// this is a wrapped invoice, we need to retry it with receiver fallbacks
|
|
try {
|
|
const { userId } = invoiceForward.wallet
|
|
// this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available
|
|
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
|
|
msats: failedInvoice.msatsRequested,
|
|
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
|
|
description: await action.describe?.(actionArgs, retryContext),
|
|
expiry: INVOICE_EXPIRE_SECS
|
|
}, retryContext)
|
|
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
|
|
} catch (err) {
|
|
console.log('failed to retry wrapped invoice, falling back to SN:', err)
|
|
}
|
|
}
|
|
|
|
invoiceArgs ??= await createSNInvoice(actionType, actionArgs, retryContext)
|
|
|
|
return await models.$transaction(async tx => {
|
|
const context = { ...retryContext, tx, invoiceArgs }
|
|
|
|
// 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, actionArgs, context)
|
|
|
|
return {
|
|
result: await action.retry?.({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
|
|
invoice,
|
|
paymentMethod: actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
|
|
}
|
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
|
}
|
|
|
|
const INVOICE_EXPIRE_SECS = 600
|
|
|
|
export class NonInvoiceablePeerError extends Error {
|
|
constructor () {
|
|
super('non invoiceable peer')
|
|
this.name = 'NonInvoiceablePeerError'
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
await assertBelowMaxPendingInvoices(context)
|
|
|
|
if (cost < 1000n) {
|
|
// sanity check
|
|
throw new Error('The cost of the action 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) {
|
|
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, predecessorId } = context
|
|
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
|
|
|
|
const db = tx ?? models
|
|
|
|
if (cost < 1000n) {
|
|
// sanity check
|
|
throw new Error('The cost of the action must be at least 1 sat')
|
|
}
|
|
|
|
const servedBolt11 = wrappedBolt11 ?? bolt11
|
|
const servedInvoice = parsePaymentRequest({ request: servedBolt11 })
|
|
const expiresAt = new Date(servedInvoice.expires_at)
|
|
|
|
const invoiceData = {
|
|
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,
|
|
actionArgs: args,
|
|
expiresAt,
|
|
actionId,
|
|
predecessorId
|
|
}
|
|
|
|
let invoice
|
|
if (wrappedBolt11) {
|
|
invoice = (await db.invoiceForward.create({
|
|
include: { invoice: true },
|
|
data: {
|
|
bolt11,
|
|
maxFeeMsats: maxFee,
|
|
invoice: {
|
|
create: invoiceData
|
|
},
|
|
wallet: {
|
|
connect: {
|
|
id: wallet.id
|
|
}
|
|
}
|
|
}
|
|
})).invoice
|
|
} else {
|
|
invoice = await db.invoice.create({ data: invoiceData })
|
|
}
|
|
|
|
// 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,
|
|
${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)
|
|
|
|
return invoice
|
|
}
|