From 79f0df17b2ba0ec372da7d74b18bbdb4b84b2665 Mon Sep 17 00:00:00 2001 From: Keyan <34140557+huumn@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:30:42 -0500 Subject: [PATCH] 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 --- api/paidAction/README.md | 8 +- api/paidAction/index.js | 128 +++++------------- api/paidAction/itemCreate.js | 12 +- api/paidAction/zap.js | 4 +- api/resolvers/item.js | 54 ++++---- api/resolvers/paidAction.js | 53 ++++++-- api/resolvers/rewards.js | 4 +- api/resolvers/sub.js | 22 +-- api/resolvers/wallet.js | 4 +- api/typeDefs/item.js | 16 +-- api/typeDefs/paidAction.js | 4 + api/typeDefs/rewards.js | 4 +- api/typeDefs/sub.js | 6 +- api/typeDefs/wallet.js | 1 + components/invoice.js | 9 +- components/item-act.js | 6 +- components/payment.js | 12 +- components/use-paid-mutation.js | 51 ++++--- docker-compose.yml | 2 + fragments/paidAction.js | 87 ++++++++---- fragments/wallet.js | 1 + pages/rewards/index.js | 6 +- .../migration.sql | 4 + prisma/schema.prisma | 3 + worker/paidAction.js | 43 +++++- worker/territory.js | 3 +- 26 files changed, 295 insertions(+), 252 deletions(-) create mode 100644 prisma/migrations/20240703144051_action_args_err/migration.sql diff --git a/api/paidAction/README.md b/api/paidAction/README.md index 0e41d303..90617eb7 100644 --- a/api/paidAction/README.md +++ b/api/paidAction/README.md @@ -25,7 +25,7 @@ For paid actions that support it, if the stacker doesn't have enough fee credits ### 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 @@ -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) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 92944e9c..413389c7 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -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) { - 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.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined context.cost = await paidAction.getCost(args, context) - if (hash || hmac || !me) { - console.log('hash or hmac provided, or anon, performing pessimistic action') + + 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.user.msats + const isRich = context.cost <= context.me.msats if (isRich) { try { console.log('enough fee credits available, performing fee credit action') @@ -62,34 +62,25 @@ 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. - // 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) } } + // 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) } - 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,39 +138,17 @@ 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' - } + // 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' } } @@ -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 -} diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js index 28f9a236..47c9847e 100644 --- a/api/paidAction/itemCreate.js +++ b/api/paidAction/itemCreate.js @@ -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) } diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index a5b42226..f5c9c169 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -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 } ] }) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 1e7364fa..dab28ec4 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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 diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js index 27d8dc7c..5d0cf1a4 100644 --- a/api/resolvers/paidAction.js +++ b/api/resolvers/paidAction.js @@ -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) } } }, diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index a7dbf682..bc407b15 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -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: { diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 32f98314..6fc0397d 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -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' } }) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index dc5120cc..8c081c24 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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 }) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index f0099e0f..b084c49f 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -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! } diff --git a/api/typeDefs/paidAction.js b/api/typeDefs/paidAction.js index 2fbfda0a..1a6c7a59 100644 --- a/api/typeDefs/paidAction.js +++ b/api/typeDefs/paidAction.js @@ -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! } diff --git a/api/typeDefs/rewards.js b/api/typeDefs/rewards.js index af5b7353..ade5fd0f 100644 --- a/api/typeDefs/rewards.js +++ b/api/typeDefs/rewards.js @@ -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 { diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index ec92d38d..a52e80bc 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -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 { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 94c4d435..faedb976 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -76,6 +76,7 @@ export default gql` confirmedPreimage: String actionState: String actionType: String + actionError: String item: Item itemAct: ItemAct } diff --git a/components/invoice.js b/components/invoice.js index b2139b64..c5ca23f2 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -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]) diff --git a/components/item-act.js b/components/item-act.js index a6061a53..5001ab1e 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -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 ? { diff --git a/components/payment.js b/components/payment.js index 253ff873..c1807c26 100644 --- a/components/payment.js +++ b/components/payment.js @@ -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 />, diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index 931b1e4a..6e55c7e1 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -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] } diff --git a/docker-compose.yml b/docker-compose.yml index 30feb4a9..72ac9865 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,8 @@ services: CONNECT: "localhost:5431" app: container_name: app + stdin_open: true + tty: true build: context: ./ args: diff --git a/fragments/paidAction.js b/fragments/paidAction.js index 777f07c1..2561c180 100644 --- a/fragments/paidAction.js +++ b/fragments/paidAction.js @@ -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 } diff --git a/fragments/wallet.js b/fragments/wallet.js index fa26c091..6acf0c3e 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -19,6 +19,7 @@ export const INVOICE_FIELDS = gql` confirmedPreimage actionState actionType + actionError }` export const INVOICE_FULL = gql` diff --git a/pages/rewards/index.js b/pages/rewards/index.js index 2e83b530..88d3f714 100644 --- a/pages/rewards/index.js +++ b/pages/rewards/index.js @@ -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() diff --git a/prisma/migrations/20240703144051_action_args_err/migration.sql b/prisma/migrations/20240703144051_action_args_err/migration.sql new file mode 100644 index 00000000..adcd4e9f --- /dev/null +++ b/prisma/migrations/20240703144051_action_args_err/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "actionArgs" JSONB, +ADD COLUMN "actionError" TEXT, +ADD COLUMN "actionResult" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a63f7b9a..c561e055 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/worker/paidAction.js b/worker/paidAction.js index d1267412..e89902f6 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -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 }) } diff --git a/worker/territory.js b/worker/territory.js index de2b1f6d..21bb87b7 100644 --- a/worker/territory.js +++ b/worker/territory.js @@ -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') }