diff --git a/api/resolvers/item.js b/api/resolvers/item.js index e238db409c..11285764e8 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1377,7 +1377,8 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo const description = 'Creating item on stacker.news' // TODO: allow users to configure expiration const expiresAt = datePivot(new Date(), { milliseconds: DEFAULT_INVOICE_TIMEOUT_MS }) - const mtokens = err.cost - err.balance + // we don't do partial payments yet, users need to pay full amount via invoice + const mtokens = err.cost const lndInv = await createInvoice({ description: me.hideInvoiceDesc ? undefined : description, @@ -1401,6 +1402,11 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo SELECT * FROM create_invoice(${lndInv.id}, NULL, ${lndInv.request}, ${expiresAt}::timestamp, ${mtokens}, ${item.userId}::INTEGER, ${description}, NULL, NULL, ${invLimit}::INTEGER, ${balanceLimit}, 'ITEM'::"ActionType", ${item.id}::INTEGER, ${JSON.stringify(actionData)}::JSONB)`) + + // since SubscribeToInvoices does not trigger if an invoice expired, we need a job that handles expired invoices + await tx.$queryRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('finalizeAction', jsonb_build_object('type', 'ITEM', 'id', ${item.id}::INTEGER), 21, true, ${expiresAt})` }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) // hmac is not required to submit action again but to allow user to cancel payment diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 7ef5112f67..6e1d802abb 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -13,6 +13,7 @@ import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' import { createInvoice as createInvoiceCLN } from '@/lib/cln' import { bolt11Tags } from '@/lib/bolt11' +import { handleActionError } from 'worker/wallet' export async function getInvoice (parent, { id }, { me, models, lnd }) { const inv = await models.invoice.findUnique({ @@ -380,7 +381,8 @@ export default { throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) } await cancelHodlInvoice({ id: hash, lnd }) - const inv = await serialize( + const inv = await models.invoice.findUnique({ where: { hash } }) + await serialize([ models.invoice.update({ where: { hash @@ -389,9 +391,10 @@ export default { cancelled: true } }), - { models } + ...handleActionError({ data: inv, models }) + ], { models } ) - return inv + return { ...inv, cancelled: true } }, dropBolt11: async (parent, { id }, { me, models, lnd }) => { if (!me) { diff --git a/prisma/migrations/20240518020747_pending_items/migration.sql b/prisma/migrations/20240518020747_pending_items/migration.sql index a4bf1c7edb..73ac800e00 100644 --- a/prisma/migrations/20240518020747_pending_items/migration.sql +++ b/prisma/migrations/20240518020747_pending_items/migration.sql @@ -190,10 +190,6 @@ BEGIN -- if item is pending, user pays missing sats later. -- all remaining queries will run when invoice was paid and we update the item status. IF item."status" = 'PENDING'::"Status" THEN - -- here, we immediately deduct as many of the sats that are required for the payment - -- as possible to effectively "lock" them for it. the remainder will be paid via invoice. - -- if the payment fails, we release the locked sats by adding them to the balance again. - UPDATE users SET msats = GREATEST(msats - cost_msats - item.boost, 0) WHERE id = item."userId"; RETURN item; END IF; diff --git a/worker/action.js b/worker/action.js new file mode 100644 index 0000000000..3e0c44256b --- /dev/null +++ b/worker/action.js @@ -0,0 +1,8 @@ +import { handleActionError } from './wallet' + +export async function finalizeAction ({ data: { type, id }, models }) { + const queries = handleActionError({ data: { actionType: type, actionId: id }, models }) + if (queries.length === 0) return + + await models.$transaction(queries) +} diff --git a/worker/index.js b/worker/index.js index 2d5b8955a1..24892b4f9e 100644 --- a/worker/index.js +++ b/worker/index.js @@ -25,6 +25,7 @@ import { ofac } from './ofac.js' import { autoWithdraw } from './autowithdraw.js' import { saltAndHashEmails } from './saltAndHashEmails.js' import { remindUser } from './reminder.js' +import { finalizeAction } from './action.js' const { loadEnvConfig } = nextEnv const { ApolloClient, HttpLink, InMemoryCache } = apolloClient @@ -80,6 +81,7 @@ async function work () { await subscribeToWallet(args) await boss.work('finalizeHodlInvoice', jobWrapper(finalizeHodlInvoice)) + await boss.work('finalizeAction', jobWrapper(finalizeAction)) await boss.work('checkPendingDeposits', jobWrapper(checkPendingDeposits)) await boss.work('checkPendingWithdrawals', jobWrapper(checkPendingWithdrawals)) await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s)) diff --git a/worker/wallet.js b/worker/wallet.js index 9d15c5706f..e7e94e7741 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -65,7 +65,8 @@ async function subscribeToDeposits (args) { } else { // this is a HODL invoice. We need to use SubscribeToInvoice which has is_held transitions // https://api.lightning.community/api/lnd/invoices/subscribe-single-invoice - // SubscribeToInvoices is only for invoice creation and settlement transitions + // SubscribeToInvoices is only for invoice creation and settlement transitions. + // This means if invoices expire or are cancelled, we will not get notified here. // https://api.lightning.community/api/lnd/lightning/subscribe-invoices subscribeToHodlInvoice({ hash: inv.id, ...args }) } @@ -165,7 +166,7 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) { } if (inv.is_canceled) { - return await serialize( + return await serialize([ models.invoice.update({ where: { hash: inv.id @@ -173,8 +174,9 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) { data: { cancelled: true } - }), { models } - ) + }), + ...handleActionError({ data: dbInv, models }) + ], { models }) } } @@ -363,8 +365,7 @@ async function handleAction ({ data: { msatsReceived, actionType, actionId, acti // update item status from PENDING to ACTIVE // and run queries which were skipped during creation - const { credits: locked, cost } = actionData - + const { cost } = actionData const item = await models.item.findUnique({ where: { id: actionId } }) await serialize([ @@ -382,7 +383,7 @@ async function handleAction ({ data: { msatsReceived, actionType, actionId, acti }, data: { msats: { - decrement: cost - msatsReceived - locked + decrement: cost } } }), @@ -395,3 +396,18 @@ async function handleAction ({ data: { msatsReceived, actionType, actionId, acti ], { models }) } } + +export function handleActionError ({ data: { actionType, actionId }, models }) { + if (!actionType || !actionId) return [] + + if (actionType === 'ITEM') { + return [ + models.$queryRaw` + UPDATE "Item" + SET status = 'FAILED' + WHERE id = ${actionId} AND status = 'PENDING'` + ] + } + + return [] +}