319 lines
9.4 KiB
JavaScript
319 lines
9.4 KiB
JavaScript
import { createHodlInvoice, createInvoice, settleHodlInvoice } 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 { timingSafeEqual } from 'crypto'
|
|
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, hash, hmac, forceFeeCredits } = context
|
|
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')
|
|
}
|
|
|
|
context.user = me ? await models.user.findUnique({ where: { id: me.id } }) : null
|
|
context.cost = await paidAction.getCost(args, context)
|
|
if (hash || hmac || !me) {
|
|
console.log('hash or hmac provided, or anon, performing pessimistic action')
|
|
return await performPessimisticAction(actionType, args, context)
|
|
}
|
|
|
|
const isRich = context.cost <= context.user.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 to do the action with fee credits, but the cost is 0, we should bail
|
|
if (context.cost === 0n) {
|
|
throw e
|
|
}
|
|
|
|
// if we fail to do the action with fee credits, we should fall back to optimistic
|
|
if (!paidAction.supportsOptimism) {
|
|
console.error('action does not support optimism and fee credits failed, performing pessimistic action')
|
|
return await performPessimisticAction(actionType, args, context)
|
|
}
|
|
}
|
|
} else {
|
|
// 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 (!paidAction.supportsOptimism) {
|
|
console.log('not enough fee credits available, optimism not supported, performing pessimistic action')
|
|
return await performPessimisticAction(actionType, args, context)
|
|
}
|
|
}
|
|
|
|
if (paidAction.supportsOptimism) {
|
|
console.log('performing optimistic action')
|
|
return await performOptimisticAction(actionType, args, context)
|
|
}
|
|
|
|
throw new Error(`This action ${actionType} could not be done`)
|
|
} 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]
|
|
|
|
return await models.$transaction(async tx => {
|
|
context.tx = tx
|
|
context.optimistic = true
|
|
|
|
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 { models, lnd } = context
|
|
const action = paidActions[actionType]
|
|
|
|
if (!action.supportsPessimism) {
|
|
throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
|
|
}
|
|
|
|
if (context.hmac) {
|
|
return await models.$transaction(async tx => {
|
|
context.tx = tx
|
|
|
|
// make sure the invoice is HELD
|
|
const invoice = await verifyPayment(context)
|
|
args.invoiceId = invoice.id
|
|
|
|
await settleHodlInvoice({ secret: invoice.preimage, lnd })
|
|
|
|
return {
|
|
result: await action.perform(args, context),
|
|
paymentMethod: 'PESSIMISTIC'
|
|
}
|
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
|
} else {
|
|
// just create the invoice and complete action when it's paid
|
|
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.user = await models.user.findUnique({ where: { id: me.id } })
|
|
return await models.$transaction(async tx => {
|
|
context.tx = tx
|
|
context.optimistic = true
|
|
|
|
// update the old invoice to RETRYING, so that it's not confused with FAILED
|
|
const { msatsRequested, actionId } = await tx.invoice.update({
|
|
where: {
|
|
id: invoiceId,
|
|
actionState: 'FAILED'
|
|
},
|
|
data: {
|
|
actionState: 'RETRYING'
|
|
}
|
|
})
|
|
|
|
context.cost = BigInt(msatsRequested)
|
|
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 }
|
|
|
|
async function createDbInvoice (actionType, args, context) {
|
|
const { user, models, tx, lnd, cost, optimistic, actionId } = context
|
|
const action = paidActions[actionType]
|
|
const db = tx ?? models
|
|
const [createLNDInvoice, expirePivot, actionState] = optimistic
|
|
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
|
|
: [createHodlInvoice, 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 lndInv = await createLNDInvoice({
|
|
description: user?.hideInvoiceDesc ? undefined : await action.describe(args, context),
|
|
lnd,
|
|
mtokens: String(cost),
|
|
expires_at: expiresAt
|
|
})
|
|
|
|
const invoice = await db.invoice.create({
|
|
data: {
|
|
hash: lndInv.id,
|
|
msatsRequested: cost,
|
|
preimage: optimistic ? undefined : lndInv.secret,
|
|
bolt11: lndInv.request,
|
|
userId: user?.id || USER_ID.anon,
|
|
actionType,
|
|
actionState,
|
|
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', ${lndInv.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
|
|
}
|
|
|
|
export async function verifyPayment ({ hash, hmac, models, cost }) {
|
|
if (!hash) {
|
|
throw new Error('hash required')
|
|
}
|
|
|
|
if (!hmac) {
|
|
throw new Error('hmac required')
|
|
}
|
|
|
|
const hmac2 = createHmac(hash)
|
|
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
|
|
throw new Error('hmac invalid')
|
|
}
|
|
|
|
const invoice = await models.invoice.findUnique({
|
|
where: {
|
|
hash,
|
|
actionState: 'HELD'
|
|
}
|
|
})
|
|
|
|
if (!invoice) {
|
|
throw new Error('invoice not found')
|
|
}
|
|
|
|
if (invoice.msatsReceived < cost) {
|
|
throw new Error('invoice amount too low')
|
|
}
|
|
|
|
return invoice
|
|
}
|