improve pessimistic paid actions by letting the server perform actions and settle invoice on `HELD` (#1253)

* get rid of hash and hmac based pessimism

* fix readme
This commit is contained in:
Keyan 2024-07-04 12:30:42 -05:00 committed by GitHub
parent 7c294478fb
commit 79f0df17b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 295 additions and 252 deletions

View File

@ -25,7 +25,7 @@ For paid actions that support it, if the stacker doesn't have enough fee credits
</details>
### Pessimistic
For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without storing the action. After the client pays the invoice, the client resends the action with proof of payment and action is executed fully. Pessimistic actions require the client to wait for the payment to complete before being visible to them and everyone else.
For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without performing the action and only storing the action's arguments. After the client pays the invoice, the server performs the action with original arguments. Pessimistic actions require the payment to complete before being visible to them and everyone else.
Internally, pessimistic flows use hold invoices. If the action doesn't succeed, the payment is cancelled and it's as if the payment never happened (ie it's a lightning native refund mechanism).
@ -34,8 +34,8 @@ Internally, pessimistic flows use hold invoices. If the action doesn't succeed,
Internally, pessimistic flows make use of a state machine that's transitioned by the invoice payment progress much like optimistic flows, but with extra steps. All pessimistic actions start in a `PENDING_HELD` state and has the following transitions:
- `PENDING_HELD` -> `HELD`: when the invoice is paid, but the action is not yet executed
- `HELD` -> `PAID`: when the invoice is paid
- `PENDING_HELD` -> `HELD`: when the invoice is paid and the action's `perform` is run and the invoice is settled
- `HELD` -> `PAID`: when the action's `onPaid` is called
- `PENDING_HELD` -> `FAILED`: when the invoice for the action expires or is cancelled
- `HELD` -> `FAILED`: when the action fails after the invoice is paid
</details>
@ -92,7 +92,7 @@ All functions have the following signature: `function(args: Object, context: Obj
`args` contains the arguments for the action as defined in the `graphql` schema. If the action is optimistic or pessimistic, `args` will contain an `invoiceId` field which can be stored alongside the paid action's data. If this is a call to `retry`, `args` will contain the original `invoiceId` and `newInvoiceId` fields.
`context` contains the following fields:
- `user`: the user performing the action (null if anonymous)
- `me`: the user performing the action (undefined if anonymous)
- `cost`: the cost of the action in msats as a `BigInt`
- `tx`: the current transaction (for anything that needs to be done atomically with the payment)
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)

View File

@ -1,9 +1,8 @@
import { createHodlInvoice, createInvoice, settleHodlInvoice } from 'ln-service'
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 { timingSafeEqual } from 'crypto'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
import * as ZAP from './zap'
@ -30,7 +29,7 @@ export const paidActions = {
export default async function performPaidAction (actionType, args, context) {
try {
const { me, models, hash, hmac, forceFeeCredits } = context
const { me, models, forceFeeCredits } = context
const paidAction = paidActions[actionType]
console.group('performPaidAction', actionType, args)
@ -39,18 +38,19 @@ export default async function performPaidAction (actionType, args, context) {
throw new Error(`Invalid action type ${actionType}`)
}
if (!me && !paidAction.anonable) {
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')
}
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')
console.log('we are anon so can only perform pessimistic action')
return await performPessimisticAction(actionType, args, context)
}
const isRich = context.cost <= context.user.msats
const isRich = context.cost <= context.me.msats
if (isRich) {
try {
console.log('enough fee credits available, performing fee credit action')
@ -62,14 +62,9 @@ export default async function performPaidAction (actionType, args, context) {
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
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.
@ -78,18 +73,14 @@ export default async function performPaidAction (actionType, args, context) {
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 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)
}
throw new Error(`This action ${actionType} could not be done`)
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
@ -147,40 +138,18 @@ async function performOptimisticAction (actionType, args, context) {
}
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
// make sure to perform before settling so we don't race with worker to onPaid
const result = await action.perform(args, context)
// XXX this might cause the interactive tx to time out
await settleHodlInvoice({ secret: invoice.preimage, lnd })
return {
result,
paymentMethod: 'PESSIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
} else {
// 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) {
@ -209,7 +178,7 @@ export async function retryPaidAction (actionType, args, context) {
}
context.optimistic = true
context.user = await models.user.findUnique({ where: { id: me.id } })
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)
@ -248,7 +217,7 @@ 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 { user, lnd, cost, optimistic } = context
const { me, lnd, cost, optimistic } = context
const action = paidActions[actionType]
const [createLNDInvoice, expirePivot] = optimistic
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE]
@ -261,7 +230,7 @@ async function createLndInvoice (actionType, args, context) {
const expiresAt = datePivot(new Date(), expirePivot)
return await createLNDInvoice({
description: user?.hideInvoiceDesc ? undefined : await action.describe(args, context),
description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
lnd,
mtokens: String(cost),
expires_at: expiresAt
@ -269,7 +238,7 @@ async function createLndInvoice (actionType, args, context) {
}
async function createDbInvoice (actionType, args, context) {
const { user, models, tx, lndInvoice, cost, optimistic, actionId } = context
const { me, models, tx, lndInvoice, cost, optimistic, actionId } = context
const db = tx ?? models
const [expirePivot, actionState] = optimistic
? [OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
@ -287,9 +256,10 @@ async function createDbInvoice (actionType, args, context) {
msatsRequested: cost,
preimage: optimistic ? undefined : lndInvoice.secret,
bolt11: lndInvoice.request,
userId: user?.id || USER_ID.anon,
userId: me?.id ?? USER_ID.anon,
actionType,
actionState,
actionArgs: args,
expiresAt,
actionId
}
@ -310,35 +280,3 @@ async function createDbInvoice (actionType, args, context) {
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
}

View File

@ -7,22 +7,22 @@ export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = true
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, user }) {
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + image fees + boost
const [{ cost }] = await models.$queryRaw`
SELECT ${baseCost}::INTEGER
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${user?.id || USER_ID.anon}::INTEGER,
${user?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
* ${user ? 1 : 100}::INTEGER
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
* ${me ? 1 : 100}::INTEGER
+ (SELECT "nUnpaid" * "imageFeeMsats"
FROM image_fees_info(${user?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
FROM image_fees_info(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost`
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon, and cost must be greater than user's balance
const freebie = (parentId || bio || sub?.allowFreebies) && cost <= baseCost && !!user && cost > user?.msats
const freebie = (parentId || bio || sub?.allowFreebies) && cost <= baseCost && !!me && cost > me?.msats
return freebie ? BigInt(0) : BigInt(cost)
}

View File

@ -27,8 +27,8 @@ export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, c
const acts = await tx.itemAct.createManyAndReturn({
data: [
{ msats: feeMsats, itemId, userId: me?.id || USER_ID.anon, act: 'FEE', ...invoiceData },
{ msats: zapMsats, itemId, userId: me?.id || USER_ID.anon, act: 'TIP', ...invoiceData }
{ msats: feeMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'FEE', ...invoiceData },
{ msats: zapMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'TIP', ...invoiceData }
]
})

View File

@ -738,34 +738,34 @@ export default {
return await deleteItemByAuthor({ models, id, item: old })
},
upsertLink: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
upsertLink: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(linkSchema, item, { models, me })
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else {
return await createItem(parent, item, { me, models, lnd, hash, hmac })
return await createItem(parent, item, { me, models, lnd })
}
},
upsertDiscussion: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
upsertDiscussion: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(discussionSchema, item, { models, me })
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else {
return await createItem(parent, item, { me, models, lnd, hash, hmac })
return await createItem(parent, item, { me, models, lnd })
}
},
upsertBounty: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
upsertBounty: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(bountySchema, item, { models, me })
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else {
return await createItem(parent, item, { me, models, lnd, hash, hmac })
return await createItem(parent, item, { me, models, lnd })
}
},
upsertPoll: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
upsertPoll: async (parent, { id, ...item }, { me, models, lnd }) => {
const numExistingChoices = id
? await models.pollOption.count({
where: {
@ -777,13 +777,13 @@ export default {
await ssValidate(pollSchema, item, { models, me, numExistingChoices })
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else {
item.pollCost = item.pollCost || POLL_COST
return await createItem(parent, item, { me, models, lnd, hash, hmac })
return await createItem(parent, item, { me, models, lnd })
}
},
upsertJob: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
upsertJob: async (parent, { id, ...item }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
}
@ -797,18 +797,18 @@ export default {
item.maxBid ??= 0
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else {
return await createItem(parent, item, { me, models, lnd, hash, hmac })
return await createItem(parent, item, { me, models, lnd })
}
},
upsertComment: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
upsertComment: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(commentSchema, item)
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else {
item = await createItem(parent, item, { me, models, lnd, hash, hmac })
item = await createItem(parent, item, { me, models, lnd })
return item
}
},
@ -824,14 +824,14 @@ export default {
return { id, noteId }
},
pollVote: async (parent, { id, hash, hmac }, { me, models, lnd }) => {
pollVote: async (parent, { id }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd, hash, hmac })
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd })
},
act: async (parent, { id, sats, act = 'TIP', idempotent, hash, hmac }, { me, models, lnd, headers }) => {
act: async (parent, { id, sats, act = 'TIP', idempotent }, { me, models, lnd, headers }) => {
assertApiKeyNotPermitted({ me })
await ssValidate(actSchema, { sats, act })
await assertGofacYourself({ models, headers })
@ -865,9 +865,9 @@ export default {
}
if (act === 'TIP') {
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd, hash, hmac })
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd })
} else if (act === 'DONT_LIKE_THIS') {
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd, hash, hmac })
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
} else {
throw new GraphQLError('unknown act', { extensions: { code: 'BAD_INPUT' } })
}
@ -1213,7 +1213,7 @@ export default {
}
}
export const updateItem = async (parent, { sub: subName, forward, ...item }, { me, models, lnd, hash, hmac }) => {
export const updateItem = async (parent, { sub: subName, forward, ...item }, { me, models, lnd }) => {
// update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } })
@ -1276,13 +1276,13 @@ export const updateItem = async (parent, { sub: subName, forward, ...item }, { m
}
item.uploadIds = uploadIdsFromText(item.text, { models })
const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd, hash, hmac })
const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd })
resultItem.comments = []
return resultItem
}
export const createItem = async (parent, { forward, ...item }, { me, models, lnd, hash, hmac }) => {
export const createItem = async (parent, { forward, ...item }, { me, models, lnd }) => {
// rename to match column name
item.subName = item.sub
delete item.sub
@ -1307,7 +1307,7 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
// mark item as created with API key
item.apiKey = me?.apiKey
const resultItem = await performPaidAction('ITEM_CREATE', item, { models, me, lnd, hash, hmac })
const resultItem = await performPaidAction('ITEM_CREATE', item, { models, me, lnd })
resultItem.comments = []
return resultItem

View File

@ -1,6 +1,44 @@
import { retryPaidAction } from '../paidAction'
import { USER_ID } from '@/lib/constants'
function paidActionType (actionType) {
switch (actionType) {
case 'ITEM_CREATE':
case 'ITEM_UPDATE':
return 'ItemPaidAction'
case 'ZAP':
case 'DOWN_ZAP':
return 'ItemActPaidAction'
case 'TERRITORY_CREATE':
case 'TERRITORY_UPDATE':
case 'TERRITORY_BILLING':
case 'TERRITORY_UNARCHIVE':
return 'SubPaidAction'
case 'DONATE':
return 'DonatePaidAction'
case 'POLL_VOTE':
return 'PollVotePaidAction'
default:
throw new Error('Unknown action type')
}
}
export default {
Query: {
paidAction: async (parent, { invoiceId }, { models, me }) => {
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id ?? USER_ID.anon } })
if (!invoice) {
throw new Error('Invoice not found')
}
return {
type: paidActionType(invoice.actionType),
invoice,
result: invoice.actionResult,
paymentMethod: invoice.preimage ? 'PESSIMISTIC' : 'OPTIMISTIC'
}
}
},
Mutation: {
retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
if (!me) {
@ -12,24 +50,11 @@ export default {
throw new Error('Invoice not found')
}
let type
if (invoice.actionType === 'ITEM_CREATE') {
type = 'ItemPaidAction'
} else if (invoice.actionType === 'ZAP') {
type = 'ItemActPaidAction'
} else if (invoice.actionType === 'POLL_VOTE') {
type = 'PollVotePaidAction'
} else if (invoice.actionType === 'DOWN_ZAP') {
type = 'ItemActPaidAction'
} else {
throw new Error('Unknown action type')
}
const result = await retryPaidAction(invoice.actionType, { invoiceId }, { models, me, lnd })
return {
...result,
type
type: paidActionType(invoice.actionType)
}
}
},

View File

@ -160,10 +160,10 @@ export default {
}
},
Mutation: {
donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => {
donateToRewards: async (parent, { sats }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount: sats })
return await performPaidAction('DONATE', { sats }, { me, models, lnd, hash, hmac })
return await performPaidAction('DONATE', { sats }, { me, models, lnd })
}
},
Reward: {

View File

@ -152,7 +152,7 @@ export default {
}
},
Mutation: {
upsertSub: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => {
upsertSub: async (parent, { ...data }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
@ -160,12 +160,12 @@ export default {
await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } })
if (data.oldName) {
return await updateSub(parent, data, { me, models, lnd, hash, hmac })
return await updateSub(parent, data, { me, models, lnd })
} else {
return await createSub(parent, data, { me, models, lnd, hash, hmac })
return await createSub(parent, data, { me, models, lnd })
}
},
paySub: async (parent, { name, hash, hmac }, { me, models, lnd }) => {
paySub: async (parent, { name }, { me, models, lnd }) => {
// check that they own the sub
const sub = await models.sub.findUnique({
where: {
@ -185,7 +185,7 @@ export default {
return sub
}
return await performPaidAction('TERRITORY_BILLING', { name }, { me, models, lnd, hash, hmac })
return await performPaidAction('TERRITORY_BILLING', { name }, { me, models, lnd })
},
toggleMuteSub: async (parent, { name }, { me, models }) => {
if (!me) {
@ -253,7 +253,7 @@ export default {
return updatedSub
},
unarchiveTerritory: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => {
unarchiveTerritory: async (parent, { ...data }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
@ -276,7 +276,7 @@ export default {
throw new GraphQLError('sub should not be archived', { extensions: { code: 'BAD_INPUT' } })
}
return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd, hash, hmac })
return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd })
}
},
Sub: {
@ -314,9 +314,9 @@ export default {
}
}
async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
async function createSub (parent, data, { me, models, lnd }) {
try {
return await performPaidAction('TERRITORY_CREATE', data, { me, models, lnd, hash, hmac })
return await performPaidAction('TERRITORY_CREATE', data, { me, models, lnd })
} catch (error) {
if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
@ -325,7 +325,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
}
}
async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash, hmac }) {
async function updateSub (parent, { oldName, ...data }, { me, models, lnd }) {
const oldSub = await models.sub.findUnique({
where: {
name: oldName,
@ -343,7 +343,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash,
}
try {
return await performPaidAction('TERRITORY_UPDATE', { oldName, ...data }, { me, models, lnd, hash, hmac })
return await performPaidAction('TERRITORY_UPDATE', { oldName, ...data }, { me, models, lnd })
} catch (error) {
if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })

View File

@ -1,6 +1,6 @@
import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, authenticatedLndGrpc, deletePayment, getPayment } from 'ln-service'
import { GraphQLError } from 'graphql'
import crypto from 'crypto'
import crypto, { timingSafeEqual } from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
@ -379,7 +379,7 @@ export default {
sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
const hmac2 = createHmac(hash)
if (hmac !== hmac2) {
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
}
await cancelHodlInvoice({ id: hash, lnd })

View File

@ -35,16 +35,16 @@ export default gql`
pinItem(id: ID): Item
subscribeItem(id: ID): Item
deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): ItemPaidAction!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): ItemPaidAction!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): ItemPaidAction!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String, pollExpiresAt: Date): ItemPaidAction!
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): ItemPaidAction!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date): ItemPaidAction!
updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): ItemPaidAction!
act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActPaidAction!
pollVote(id: ID!, hash: String, hmac: String): PollVotePaidAction!
upsertComment(id:ID, text: String!, parentId: ID): ItemPaidAction!
act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction!
pollVote(id: ID!): PollVotePaidAction!
toggleOutlaw(id: ID!): Item!
}

View File

@ -2,6 +2,10 @@ import { gql } from 'graphql-tag'
export default gql`
extend type Query {
paidAction(invoiceId: Int!): PaidAction
}
extend type Mutation {
retryPaidAction(invoiceId: Int!): PaidAction!
}

View File

@ -7,11 +7,11 @@ export default gql`
}
extend type Mutation {
donateToRewards(sats: Int!, hash: String, hmac: String): DonatePaidAction!
donateToRewards(sats: Int!): DonatePaidAction!
}
type DonateResult {
sats: Int
sats: Int!
}
type Rewards {

View File

@ -18,15 +18,15 @@ export default gql`
upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): SubPaidAction!
paySub(name: String!, hash: String, hmac: String): SubPaidAction!
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
paySub(name: String!): SubPaidAction!
toggleMuteSub(name: String!): Boolean!
toggleSubSubscription(name: String!): Boolean!
transferTerritory(subName: String!, userName: String!): Sub
unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): SubPaidAction!
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
}
type Sub {

View File

@ -76,6 +76,7 @@ export default gql`
confirmedPreimage: String
actionState: String
actionType: String
actionError: String
item: Item
itemAct: ItemAct
}

View File

@ -14,7 +14,11 @@ import Item from './item'
import { CommentFlat } from './comment'
import classNames from 'classnames'
export default function Invoice ({ id, query = INVOICE, modal, onPayment, info, successVerb, webLn = true, webLnError, poll, waitFor, ...props }) {
export default function Invoice ({
id, query = INVOICE, modal, onPayment, onCanceled,
info, successVerb, webLn = true, webLnError,
poll, waitFor, ...props
}) {
const [expired, setExpired] = useState(false)
const { data, error } = useQuery(query, SSR
? {}
@ -34,6 +38,9 @@ export default function Invoice ({ id, query = INVOICE, modal, onPayment, info,
if (waitFor?.(invoice)) {
onPayment?.(invoice)
}
if (invoice.cancelled || invoice.actionError) {
onCanceled?.(invoice)
}
setExpired(new Date(invoice.expiredAt) <= new Date())
}, [invoice, onPayment, setExpired])

View File

@ -61,7 +61,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
const act = useAct()
const strike = useLightning()
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
const onSubmit = useCallback(async ({ amount }) => {
if (abortSignal && zapUndoTrigger({ me, amount })) {
onClose?.()
try {
@ -76,9 +76,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
variables: {
id: item.id,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP',
hash,
hmac
act: down ? 'DONT_LIKE_THIS' : 'TIP'
},
optimisticResponse: me
? {

View File

@ -9,9 +9,10 @@ import { useFeeButton } from './fee-button'
import { useShowModal } from './modal'
export class InvoiceCanceledError extends Error {
constructor (hash) {
super(`invoice canceled: ${hash}`)
constructor (hash, actionError) {
super(actionError ?? `invoice canceled: ${hash}`)
this.name = 'InvoiceCanceledError'
this.hash = hash
}
}
@ -65,10 +66,10 @@ export const useInvoice = () => {
if (error) {
throw error
}
const { hash, cancelled } = data.invoice
const { hash, cancelled, actionError } = data.invoice
if (cancelled) {
throw new InvoiceCanceledError(hash)
if (cancelled || actionError) {
throw new InvoiceCanceledError(hash, actionError)
}
return that(data.invoice)
@ -187,6 +188,7 @@ export const useQrPayment = () => {
webLn={false}
webLnError={webLnError}
waitFor={waitFor}
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }}
onPayment={() => { paid = true; onClose(); resolve() }}
poll
/>,

View File

@ -1,6 +1,7 @@
import { useApolloClient, useMutation } from '@apollo/client'
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, useInvoice, useQrPayment, useWebLnPayment } from './payment'
import { InvoiceCanceledError, InvoiceExpiredError, useQrPayment, useWebLnPayment } from './payment'
import { GET_PAID_ACTION } from '@/fragments/paidAction'
/*
this is just like useMutation with a few changes:
@ -18,18 +19,20 @@ export function usePaidMutation (mutation,
{ onCompleted, ...options } = {}) {
options.optimisticResponse = addOptimisticResponseExtras(options.optimisticResponse)
const [mutate, result] = useMutation(mutation, options)
const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, {
fetchPolicy: 'network-only'
})
const waitForWebLnPayment = useWebLnPayment()
const waitForQrPayment = useQrPayment()
const invoiceWaiter = useInvoice()
const client = useApolloClient()
// innerResult is used to store/control the result of the mutation when innerMutate runs
const [innerResult, setInnerResult] = useState(result)
const waitForPayment = useCallback(async (invoice, persistOnNavigate = false) => {
const waitForPayment = useCallback(async (invoice, { persistOnNavigate = false, waitFor }) => {
let webLnError
const start = Date.now()
try {
return await waitForWebLnPayment(invoice)
return await waitForWebLnPayment(invoice, waitFor)
} catch (err) {
if (Date.now() - start > 1000 || err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
// bail since qr code payment will also fail
@ -38,7 +41,7 @@ export function usePaidMutation (mutation,
}
webLnError = err
}
return await waitForQrPayment(invoice, webLnError, { persistOnNavigate })
return await waitForQrPayment(invoice, webLnError, { persistOnNavigate, waitFor })
}, [waitForWebLnPayment, waitForQrPayment])
const innerMutate = useCallback(async ({
@ -48,7 +51,7 @@ export function usePaidMutation (mutation,
let { data, ...rest } = await mutate(innerOptions)
// use the most inner callbacks/options if they exist
const { onPaid, onPayError, forceWaitForPayment, persistOnNavigate } = { ...options, ...innerOptions }
const { onPaid, onPayError, forceWaitForPayment, persistOnNavigate, update } = { ...options, ...innerOptions }
const ourOnCompleted = innerOnCompleted || onCompleted
// get invoice without knowing the mutation name
@ -65,7 +68,7 @@ export function usePaidMutation (mutation,
// onCompleted is called before the invoice is paid for optimistic updates
ourOnCompleted?.(data)
// don't wait to pay the invoice
waitForPayment(invoice, persistOnNavigate).then(() => {
waitForPayment(invoice, { persistOnNavigate }).then(() => {
onPaid?.(client.cache, { data })
}).catch(e => {
console.error('usePaidMutation: failed to pay invoice', e)
@ -75,30 +78,24 @@ export function usePaidMutation (mutation,
setInnerResult(r => ({ payError: e, ...r }))
})
} else {
// the action is pessimistic
try {
// wait for the invoice to be held
await waitForPayment(invoice, persistOnNavigate);
// and the mutation to complete
({ data, ...rest } = await mutate({
...innerOptions,
variables: {
...options.variables,
...innerOptions.variables,
hmac: invoice.hmac,
hash: invoice.hash
// wait for the invoice to be paid
await waitForPayment(invoice, { persistOnNavigate, waitFor: inv => inv?.actionState === 'PAID' })
if (!response.result) {
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
// create new data object
data = { [Object.keys(data)[0]]: paidAction }
// we need to run update functions on mutations now that we have the data
update?.(client.cache, { data })
}
}))
ourOnCompleted?.(data)
onPaid?.(client.cache, { data })
// block until the invoice to be marked as paid
// for pessimisitic actions, they won't show up on navigation until they are marked as paid
await invoiceWaiter.waitUntilPaid(invoice, inv => inv?.actionState === 'PAID')
} catch (e) {
console.error('usePaidMutation: failed to pay invoice', e)
onPayError?.(e, client.cache, { data })
rest = { payError: e, ...rest }
} finally {
invoiceWaiter.stopWaiting()
rest = { ...rest, payError: e, error: e }
}
}
} else {
@ -109,7 +106,7 @@ export function usePaidMutation (mutation,
setInnerResult({ data, ...rest })
return { data, ...rest }
}, [mutate, options, waitForPayment, onCompleted, client.cache])
}, [mutate, options, waitForPayment, onCompleted, client.cache, getPaidAction, setInnerResult])
return [innerMutate, innerResult]
}

View File

@ -37,6 +37,8 @@ services:
CONNECT: "localhost:5431"
app:
container_name: app
stdin_open: true
tty: true
build:
context: ./
args:

View File

@ -36,6 +36,39 @@ const ITEM_ACT_PAID_ACTION_FIELDS = gql`
}
}`
export const GET_PAID_ACTION = gql`
${PAID_ACTION}
${ITEM_PAID_ACTION_FIELDS}
${ITEM_ACT_PAID_ACTION_FIELDS}
${SUB_FULL_FIELDS}
query paidAction($invoiceId: Int!) {
paidAction(invoiceId: $invoiceId) {
__typename
...PaidActionFields
... on ItemPaidAction {
...ItemPaidActionFields
}
... on ItemActPaidAction {
...ItemActPaidActionFields
}
... on PollVotePaidAction {
result {
id
}
}
... on SubPaidAction {
result {
...SubFullFields
}
}
... on DonatePaidAction {
result {
sats
}
}
}
}`
export const RETRY_PAID_ACTION = gql`
${PAID_ACTION}
${ITEM_PAID_ACTION_FIELDS}
@ -60,8 +93,8 @@ export const RETRY_PAID_ACTION = gql`
export const DONATE = gql`
${PAID_ACTION}
mutation donateToRewards($sats: Int!, $hash: String, $hmac: String) {
donateToRewards(sats: $sats, hash: $hash, hmac: $hmac) {
mutation donateToRewards($sats: Int!) {
donateToRewards(sats: $sats) {
result {
sats
}
@ -72,8 +105,8 @@ export const DONATE = gql`
export const ACT_MUTATION = gql`
${PAID_ACTION}
${ITEM_ACT_PAID_ACTION_FIELDS}
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
mutation act($id: ID!, $sats: Int!, $act: String) {
act(id: $id, sats: $sats, act: $act) {
...ItemActPaidActionFields
...PaidActionFields
}
@ -82,9 +115,9 @@ export const ACT_MUTATION = gql`
export const UPSERT_DISCUSSION = gql`
${PAID_ACTION}
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String,
$boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
$boost: Int, $forward: [ItemForwardInput]) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost,
forward: $forward, hash: $hash, hmac: $hmac) {
forward: $forward) {
result {
id
deleteScheduledAt
@ -98,10 +131,10 @@ export const UPSERT_JOB = gql`
${PAID_ACTION}
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!,
$location: String, $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!,
$status: String, $logo: Int, $hash: String, $hmac: String) {
$status: String, $logo: Int) {
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) {
url: $url, maxBid: $maxBid, status: $status, logo: $logo) {
result {
id
deleteScheduledAt
@ -114,9 +147,9 @@ export const UPSERT_JOB = gql`
export const UPSERT_LINK = gql`
${PAID_ACTION}
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!,
$text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
$text: String, $boost: Int, $forward: [ItemForwardInput]) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text,
boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
boost: $boost, forward: $forward) {
result {
id
deleteScheduledAt
@ -129,11 +162,9 @@ export const UPSERT_LINK = gql`
export const UPSERT_POLL = gql`
${PAID_ACTION}
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String,
$hmac: String, $pollExpiresAt: Date) {
$options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $pollExpiresAt: Date) {
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward, hash: $hash,
hmac: $hmac, pollExpiresAt: $pollExpiresAt) {
options: $options, boost: $boost, forward: $forward, pollExpiresAt: $pollExpiresAt) {
result {
id
deleteScheduledAt
@ -146,9 +177,9 @@ export const UPSERT_POLL = gql`
export const UPSERT_BOUNTY = gql`
${PAID_ACTION}
mutation upsertBounty($sub: String, $id: ID, $title: String!, $bounty: Int!,
$text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
$text: String, $boost: Int, $forward: [ItemForwardInput]) {
upsertBounty(sub: $sub, id: $id, title: $title, bounty: $bounty, text: $text,
boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
boost: $boost, forward: $forward) {
result {
id
deleteScheduledAt
@ -160,8 +191,8 @@ export const UPSERT_BOUNTY = gql`
export const POLL_VOTE = gql`
${PAID_ACTION}
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
pollVote(id: $id, hash: $hash, hmac: $hmac) {
mutation pollVote($id: ID!) {
pollVote(id: $id) {
result {
id
}
@ -172,8 +203,8 @@ export const POLL_VOTE = gql`
export const CREATE_COMMENT = gql`
${ITEM_PAID_ACTION_FIELDS}
${PAID_ACTION}
mutation upsertComment($text: String!, $parentId: ID!, $hash: String, $hmac: String) {
upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) {
mutation upsertComment($text: String!, $parentId: ID!) {
upsertComment(text: $text, parentId: $parentId) {
...ItemPaidActionFields
...PaidActionFields
}
@ -182,8 +213,8 @@ export const CREATE_COMMENT = gql`
export const UPDATE_COMMENT = gql`
${ITEM_PAID_ACTION_FIELDS}
${PAID_ACTION}
mutation upsertComment($id: ID!, $text: String!, $hash: String, $hmac: String) {
upsertComment(id: $id, text: $text, hash: $hash, hmac: $hmac) {
mutation upsertComment($id: ID!, $text: String!) {
upsertComment(id: $id, text: $text) {
...ItemPaidActionFields
...PaidActionFields
}
@ -193,10 +224,10 @@ export const UPSERT_SUB = gql`
${PAID_ACTION}
mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
$billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
result {
name
}
@ -208,10 +239,10 @@ export const UNARCHIVE_TERRITORY = gql`
${PAID_ACTION}
mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
$billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
result {
name
}
@ -222,8 +253,8 @@ export const UNARCHIVE_TERRITORY = gql`
export const SUB_PAY = gql`
${SUB_FULL_FIELDS}
${PAID_ACTION}
mutation paySub($name: String!, $hash: String, $hmac: String) {
paySub(name: $name, hash: $hash, hmac: $hmac) {
mutation paySub($name: String!) {
paySub(name: $name) {
result {
...SubFullFields
}

View File

@ -19,6 +19,7 @@ export const INVOICE_FIELDS = gql`
confirmedPreimage
actionState
actionType
actionError
}`
export const INVOICE_FULL = gql`

View File

@ -172,12 +172,10 @@ export function DonateButton () {
amount: 10000
}}
schema={amountSchema}
onSubmit={async ({ amount, hash, hmac }) => {
onSubmit={async ({ amount }) => {
const { error } = await donateToRewards({
variables: {
sats: Number(amount),
hash,
hmac
sats: Number(amount)
},
onCompleted: () => {
strike()

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "actionArgs" JSONB,
ADD COLUMN "actionError" TEXT,
ADD COLUMN "actionResult" JSONB;

View File

@ -782,6 +782,9 @@ model Invoice {
actionState InvoiceActionState?
actionType InvoiceActionType?
actionId Int?
actionArgs Json? @db.JsonB
actionError String?
actionResult Json? @db.JsonB
ItemAct ItemAct[]
Item Item[]
Upload Upload[]

View File

@ -1,16 +1,21 @@
import { paidActions } from '@/api/paidAction'
import { datePivot } from '@/lib/time'
import { Prisma } from '@prisma/client'
import { getInvoice } from 'ln-service'
import { getInvoice, settleHodlInvoice } from 'ln-service'
async function transitionInvoice (jobName, { invoiceId, fromState, toState, toData, onTransition }, { models, lnd, boss }) {
console.group(`${jobName}: transitioning invoice ${invoiceId} from ${fromState} to ${toState}`)
let dbInvoice
try {
console.log('fetching invoice from db')
dbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
console.log('invoice is in state', dbInvoice.actionState)
if (['FAILED', 'PAID'].includes(dbInvoice.actionState)) {
console.log('invoice is already in a terminal state, skipping transition')
return
}
const lndInvoice = await getInvoice({ id: dbInvoice.hash, lnd })
const data = toData(lndInvoice)
@ -18,10 +23,9 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, toDa
fromState = [fromState]
}
console.log('invoice is in state', dbInvoice.actionState)
await models.$transaction(async tx => {
dbInvoice = await tx.invoice.update({
include: { user: true },
where: {
id: invoiceId,
actionState: {
@ -105,9 +109,36 @@ export async function holdAction ({ data: { invoiceId }, models, lnd, boss }) {
onTransition: async ({ dbInvoice, tx }) => {
// make sure settled or cancelled in 60 seconds to minimize risk of force closures
const expiresAt = new Date(Math.min(dbInvoice.expiresAt, datePivot(new Date(), { seconds: 60 })))
await tx.$executeRaw`
// do outside of transaction because we don't want this to rollback if the rest of the job fails
await models.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('finalizeHodlInvoice', jsonb_build_object('hash', ${dbInvoice.hash}), 21, true, ${expiresAt})`
// perform the action now that we have the funds
try {
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const result = await paidActions[dbInvoice.actionType].perform(args,
{ models, tx, lnd, cost: dbInvoice.msatsReceived, me: dbInvoice.user })
await tx.invoice.update({
where: { id: dbInvoice.id },
data: {
actionResult: result
}
})
} catch (e) {
// store the error in the invoice, nonblocking and outside of this tx, finalizing immediately
models.invoice.update({
where: { id: dbInvoice.id },
data: {
actionError: e.message
}
}).catch(e => console.error('failed to cancel invoice', e))
boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash })
throw e
}
// settle the invoice, allowing us to transition to PAID
await settleHodlInvoice({ secret: dbInvoice.preimage, lnd })
}
}, { models, lnd, boss })
}

View File

@ -14,6 +14,7 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
async function territoryStatusUpdate () {
if (sub.status !== 'STOPPED') {
await models.sub.update({
include: { user: true },
where: {
name: subName
},
@ -35,7 +36,7 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
try {
const { result } = await performPaidAction('TERRITORY_BILLING',
{ name: subName }, { models, me: { id: sub.userId }, lnd, forceFeeCredits: true })
{ name: subName }, { models, me: sub.user, lnd, forceFeeCredits: true })
if (!result) {
throw new Error('not enough fee credits to auto-renew territory')
}