283 lines
8.7 KiB
JavaScript
283 lines
8.7 KiB
JavaScript
import { createHodlInvoice, createInvoice } from 'ln-service'
|
|
import { datePivot } from '@/lib/time'
|
|
import { USER_ID } from '@/lib/constants'
|
|
import { createHmac } from '../resolvers/wallet'
|
|
import { Prisma } from '@prisma/client'
|
|
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'
|
|
|
|
export const paidActions = {
|
|
ITEM_CREATE,
|
|
ITEM_UPDATE,
|
|
ZAP,
|
|
DOWN_ZAP,
|
|
POLL_VOTE,
|
|
TERRITORY_CREATE,
|
|
TERRITORY_UPDATE,
|
|
TERRITORY_BILLING,
|
|
TERRITORY_UNARCHIVE,
|
|
DONATE
|
|
}
|
|
|
|
export default async function performPaidAction (actionType, args, context) {
|
|
try {
|
|
const { me, models, forceFeeCredits } = context
|
|
const paidAction = paidActions[actionType]
|
|
|
|
console.group('performPaidAction', actionType, args)
|
|
|
|
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)
|
|
|
|
if (!me) {
|
|
if (!paidAction.anonable) {
|
|
throw new Error('You must be logged in to perform this action')
|
|
}
|
|
|
|
console.log('we are anon so can only perform pessimistic action')
|
|
return await performPessimisticAction(actionType, args, context)
|
|
}
|
|
|
|
const isRich = context.cost <= context.me.msats
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 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)
|
|
}
|
|
|
|
console.error('action does not support optimism and fee credits failed, performing pessimistic action')
|
|
return await performPessimisticAction(actionType, args, context)
|
|
} catch (e) {
|
|
console.error('performPaidAction failed', e)
|
|
throw e
|
|
} finally {
|
|
console.groupEnd()
|
|
}
|
|
}
|
|
|
|
async function performFeeCreditAction (actionType, args, context) {
|
|
const { me, models, cost } = context
|
|
const action = paidActions[actionType]
|
|
|
|
return await models.$transaction(async tx => {
|
|
context.tx = tx
|
|
|
|
await tx.user.update({
|
|
where: {
|
|
id: me.id
|
|
},
|
|
data: {
|
|
msats: {
|
|
decrement: cost
|
|
}
|
|
}
|
|
})
|
|
|
|
const result = await action.perform(args, context)
|
|
await action.onPaid?.(result, context)
|
|
|
|
return {
|
|
result,
|
|
paymentMethod: 'FEE_CREDIT'
|
|
}
|
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
|
}
|
|
|
|
async function performOptimisticAction (actionType, args, context) {
|
|
const { models } = context
|
|
const action = paidActions[actionType]
|
|
|
|
context.optimistic = true
|
|
context.lndInvoice = await createLndInvoice(actionType, args, context)
|
|
|
|
return await models.$transaction(async tx => {
|
|
context.tx = tx
|
|
|
|
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 performPessimisticAction (actionType, args, context) {
|
|
const action = paidActions[actionType]
|
|
|
|
if (!action.supportsPessimism) {
|
|
throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
|
|
}
|
|
|
|
// just create the invoice and complete action when it's paid
|
|
context.lndInvoice = await createLndInvoice(actionType, args, context)
|
|
return {
|
|
invoice: await createDbInvoice(actionType, args, context),
|
|
paymentMethod: 'PESSIMISTIC'
|
|
}
|
|
}
|
|
|
|
export async function retryPaidAction (actionType, args, context) {
|
|
const { models, me } = context
|
|
const { invoiceId } = 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.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 (!invoiceId) {
|
|
throw new Error(`retryPaidAction - missing invoiceId ${actionType}`)
|
|
}
|
|
|
|
context.optimistic = true
|
|
context.me = await models.user.findUnique({ where: { id: me.id } })
|
|
|
|
const { msatsRequested } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
|
|
context.cost = BigInt(msatsRequested)
|
|
context.lndInvoice = await createLndInvoice(actionType, args, context)
|
|
|
|
return await models.$transaction(async tx => {
|
|
context.tx = tx
|
|
|
|
// update the old invoice to RETRYING, so that it's not confused with FAILED
|
|
const { actionId } = await tx.invoice.update({
|
|
where: {
|
|
id: invoiceId,
|
|
actionState: 'FAILED'
|
|
},
|
|
data: {
|
|
actionState: 'RETRYING'
|
|
}
|
|
})
|
|
|
|
context.actionId = actionId
|
|
|
|
// create a new invoice
|
|
const invoice = await createDbInvoice(actionType, args, context)
|
|
|
|
return {
|
|
result: await action.retry({ invoiceId, newInvoiceId: invoice.id }, context),
|
|
invoice,
|
|
paymentMethod: 'OPTIMISTIC'
|
|
}
|
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
|
}
|
|
|
|
const OPTIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
|
|
const PESSIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
|
|
|
|
// we seperate the invoice creation into two functions because
|
|
// because if lnd is slow, it'll timeout the interactive tx
|
|
async function createLndInvoice (actionType, args, context) {
|
|
const { me, lnd, cost, optimistic } = context
|
|
const action = paidActions[actionType]
|
|
const [createLNDInvoice, expirePivot] = optimistic
|
|
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE]
|
|
: [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE]
|
|
|
|
if (cost < 1000n) {
|
|
// sanity check
|
|
throw new Error('The cost of the action must be at least 1 sat')
|
|
}
|
|
|
|
const expiresAt = datePivot(new Date(), expirePivot)
|
|
return await createLNDInvoice({
|
|
description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
|
|
lnd,
|
|
mtokens: String(cost),
|
|
expires_at: expiresAt
|
|
})
|
|
}
|
|
|
|
async function createDbInvoice (actionType, args, context) {
|
|
const { me, models, tx, lndInvoice, cost, optimistic, actionId } = context
|
|
const db = tx ?? models
|
|
const [expirePivot, actionState] = optimistic
|
|
? [OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
|
|
: [PESSIMISTIC_INVOICE_EXPIRE, 'PENDING_HELD']
|
|
|
|
if (cost < 1000n) {
|
|
// sanity check
|
|
throw new Error('The cost of the action must be at least 1 sat')
|
|
}
|
|
|
|
const expiresAt = datePivot(new Date(), expirePivot)
|
|
const invoice = await db.invoice.create({
|
|
data: {
|
|
hash: lndInvoice.id,
|
|
msatsRequested: cost,
|
|
preimage: optimistic ? undefined : lndInvoice.secret,
|
|
bolt11: lndInvoice.request,
|
|
userId: me?.id ?? USER_ID.anon,
|
|
actionType,
|
|
actionState,
|
|
actionArgs: args,
|
|
expiresAt,
|
|
actionId
|
|
}
|
|
})
|
|
|
|
// 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', ${lndInvoice.id}::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
|
|
}
|