338 lines
10 KiB
JavaScript
338 lines
10 KiB
JavaScript
import { createHodlInvoice, createInvoice, parsePaymentRequest } 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'
|
|
import wrapInvoice from 'wallets/wrap'
|
|
import { createInvoice as createUserInvoice } from 'wallets/server'
|
|
|
|
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
|
|
const invoiceArgs = await createLightningInvoice(actionType, args, context)
|
|
|
|
return await models.$transaction(async tx => {
|
|
context.tx = tx
|
|
|
|
const invoice = await createDbInvoice(actionType, args, context, invoiceArgs)
|
|
|
|
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
|
|
const invoiceArgs = await createLightningInvoice(actionType, args, context)
|
|
return {
|
|
invoice: await createDbInvoice(actionType, args, context, invoiceArgs),
|
|
paymentMethod: 'PESSIMISTIC'
|
|
}
|
|
}
|
|
|
|
export async function retryPaidAction (actionType, args, context) {
|
|
const { models, me } = context
|
|
const { invoiceId } = args
|
|
|
|
console.log('retryPaidAction', actionType, args)
|
|
|
|
const action = paidActions[actionType]
|
|
if (!action) {
|
|
throw new Error(`retryPaidAction - invalid action type ${actionType}`)
|
|
}
|
|
|
|
if (!me) {
|
|
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
|
|
}
|
|
|
|
if (!action.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, actionId } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
|
|
context.cost = BigInt(msatsRequested)
|
|
context.actionId = actionId
|
|
const invoiceArgs = await createSNInvoice(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
|
|
await tx.invoice.update({
|
|
where: {
|
|
id: invoiceId,
|
|
actionState: 'FAILED'
|
|
},
|
|
data: {
|
|
actionState: 'RETRYING'
|
|
}
|
|
})
|
|
|
|
// create a new invoice
|
|
const invoice = await createDbInvoice(actionType, args, context, invoiceArgs)
|
|
|
|
return {
|
|
result: await action.retry({ invoiceId, newInvoiceId: invoice.id }, context),
|
|
invoice,
|
|
paymentMethod: 'OPTIMISTIC'
|
|
}
|
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
|
}
|
|
|
|
const INVOICE_EXPIRE_SECS = 600
|
|
|
|
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 } = context
|
|
const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context)
|
|
|
|
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 1/10th is the fee
|
|
msats: cost * BigInt(9) / BigInt(10),
|
|
description,
|
|
expiry: INVOICE_EXPIRE_SECS
|
|
}, { models })
|
|
|
|
const { invoice: wrappedInvoice, maxFee } = await wrapInvoice(
|
|
bolt11, { msats: cost, description }, { lnd })
|
|
|
|
return {
|
|
bolt11,
|
|
wrappedBolt11: wrappedInvoice.request,
|
|
wallet,
|
|
maxFee
|
|
}
|
|
} catch (e) {
|
|
console.error('failed to create stacker invoice, falling back to SN invoice', e)
|
|
}
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
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: optimistic ? undefined : preimage,
|
|
bolt11: servedBolt11,
|
|
userId: me?.id ?? USER_ID.anon,
|
|
actionType,
|
|
actionState: wrappedBolt11 ? 'PENDING_HELD' : optimistic ? 'PENDING' : 'PENDING_HELD',
|
|
actionOptimistic: optimistic,
|
|
actionArgs: args,
|
|
expiresAt,
|
|
actionId
|
|
}
|
|
|
|
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
|
|
}
|