From 32847670e2925bad1beb7aa18ad9b057aa5bcf5e Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 26 Sep 2023 15:15:09 -0500 Subject: [PATCH] reduce duplication of invoicable code --- api/resolvers/item.js | 198 ++++++++------------------------------- api/resolvers/rewards.js | 38 ++------ api/resolvers/serial.js | 75 ++++++++++++++- 3 files changed, 120 insertions(+), 191 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index df7dd7528..138ef9894 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1,6 +1,6 @@ import { GraphQLError } from 'graphql' import { ensureProtocol, removeTracking } from '../../lib/url' -import serialize from './serial' +import { serializeInvoicable } from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import domino from 'domino' @@ -16,8 +16,6 @@ import { advSchema, amountSchema, bountySchema, commentSchema, discussionSchema, import { sendUserNotification } from '../webPush' import { proxyImages } from './imgproxy' import { defaultCommentSort } from '../../lib/item' -import { createHmac } from './wallet' -import { settleHodlInvoice } from 'ln-service' export async function commentFilterClause (me, models) { let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` @@ -38,51 +36,6 @@ export async function commentFilterClause (me, models) { return clause } -export async function checkInvoice (models, hash, hmac, fee) { - if (!hash) { - throw new GraphQLError('hash required', { extensions: { code: 'BAD_INPUT' } }) - } - if (!hmac) { - throw new GraphQLError('hmac required', { extensions: { code: 'BAD_INPUT' } }) - } - const hmac2 = createHmac(hash) - if (hmac !== hmac2) { - throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) - } - - const invoice = await models.invoice.findUnique({ - where: { hash }, - include: { - user: true - } - }) - - if (!invoice) { - throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) - } - - const expired = new Date(invoice.expiresAt) <= new Date() - if (expired) { - throw new GraphQLError('invoice expired', { extensions: { code: 'BAD_INPUT' } }) - } - if (invoice.confirmedAt) { - throw new GraphQLError('invoice already used', { extensions: { code: 'BAD_INPUT' } }) - } - - if (invoice.cancelled) { - throw new GraphQLError('invoice was canceled', { extensions: { code: 'BAD_INPUT' } }) - } - - if (!invoice.msatsReceived) { - throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } }) - } - if (fee && msatsToSats(invoice.msatsReceived) < fee) { - throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } }) - } - - return invoice -} - async function comments (me, models, id, sort) { let orderBy switch (sort) { @@ -714,72 +667,45 @@ export default { return rItem } }, - pollVote: async (parent, { id, hash, hmac }, { me, models }) => { + pollVote: async (parent, { id, hash, hmac }, { me, models, lnd }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } - let invoice - if (hash) { - invoice = await checkInvoice(models, hash, hmac) - } - - const trx = [ - models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)) - ] - if (invoice) { - trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`) - trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })) - } - - await serialize(models, ...trx) + await serializeInvoicable( + models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)), + { me, models, lnd, hash, hmac } + ) return id }, act: async (parent, { id, sats, hash, hmac }, { me, models, lnd }) => { - // need to make sure we are logged in - if (!me && !hash) { - throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } }) - } - await ssValidate(amountSchema, { amount: sats }) - let user = me - let invoice - if (hash) { - invoice = await checkInvoice(models, hash, hmac, sats) - if (!me) user = invoice.user - } - // disallow self tips except anons - if (user.id !== ANON_USER_ID) { + if (me) { const [item] = await models.$queryRawUnsafe(` ${SELECT} FROM "Item" - WHERE id = $1 AND "userId" = $2`, Number(id), user.id) + WHERE id = $1 AND "userId" = $2`, Number(id), me.id) if (item) { throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } }) } - } - - // Disallow tips if me is one of the forward user recipients - const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } }) - if (existingForwards.some(fwd => Number(fwd.userId) === Number(user.id))) { - throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } }) - } - const trx = [ - models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` - ] - if (invoice) { - trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`) - trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })) + // Disallow tips if me is one of the forward user recipients + const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } }) + if (existingForwards.some(fwd => Number(fwd.userId) === Number(me.id))) { + throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } }) + } } - const query = await serialize(models, ...trx) - const { item_act: vote } = trx.length > 1 ? query[1][0] : query[0] - - if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd }) + const { item_act: vote } = await serializeInvoicable( + models.$queryRaw` + SELECT + item_act(${Number(id)}::INTEGER, + ${me?.id || ANON_USER_ID}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`, + { me, models, lnd, hash, hmac, enforceFee: sats } + ) const notify = async () => { try { @@ -837,17 +763,12 @@ export default { sats } }, - dontLikeThis: async (parent, { id, sats, hash, hmac }, { me, models }) => { + dontLikeThis: async (parent, { id, sats = DONT_LIKE_THIS_COST, hash, hmac }, { me, lnd, models }) => { // need to make sure we are logged in if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } - let invoice - if (hash) { - invoice = await checkInvoice(models, hash, hmac, DONT_LIKE_THIS_COST) - } - // disallow self down votes const [item] = await models.$queryRawUnsafe(` ${SELECT} @@ -857,16 +778,11 @@ export default { throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } }) } - const trx = [ + await serializeInvoicable( models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, - ${me.id}::INTEGER, 'DONT_LIKE_THIS', ${sats || DONT_LIKE_THIS_COST}::INTEGER)` - ] - if (invoice) { - trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`) - trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })) - } - - await serialize(models, ...trx) + ${me.id}::INTEGER, 'DONT_LIKE_THIS', ${sats}::INTEGER)`, + { me, models, lnd, hash, hmac } + ) return true } @@ -1126,6 +1042,7 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) } + // in case they lied about their existing boost await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost }) // if it's not the FAQ, not their bio, and older than 10 minutes @@ -1157,29 +1074,16 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it item = { subName, userId: me.id, ...item } const fwdUsers = await getForwardUsers(models, forward) - let invoice - if (hash) { - invoice = await checkInvoice(models, hash, hmac) - } - - const trx = [ + item = await serializeInvoicable( models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`, - JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)) - ] - if (invoice) { - trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`) - trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })) - } + JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)), + { models, lnd, hash, hmac, me } + ) - const query = await serialize(models, ...trx) - const rItem = trx.length > 1 ? query[1][0] : query[0] - - if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd }) - - await createMentions(rItem, models) + await createMentions(item, models) - rItem.comments = [] - return rItem + item.comments = [] + return item } export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, hash, hmac }) => { @@ -1189,21 +1093,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo item.subName = item.sub delete item.sub - if (!me && !hash) { - throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } }) - } - let invoice - if (hash) { - // if we are logged in, we don't compare the invoice amount with the fee - // since it's not a fixed amount that we could use here. - // we rely on the query telling us if the balance is too low - const fee = !me ? (item.parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) : undefined - invoice = await checkInvoice(models, hash, hmac, fee) - item.userId = invoice.user.id - } - if (me) { - item.userId = Number(me.id) - } + item.userId = me ? Number(me.id) : ANON_USER_ID const fwdUsers = await getForwardUsers(models, forward) if (item.text) { @@ -1215,19 +1105,13 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo item.url = await proxyImages(item.url) } - const trx = [ - models.$queryRawUnsafe(`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`, - JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)) - ] - if (invoice) { - trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`) - trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })) - } - - const query = await serialize(models, ...trx) - item = trx.length > 1 ? query[1][0] : query[0] - - if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd }) + const enforceFee = me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0))) + item = await serializeInvoicable( + models.$queryRawUnsafe( + `${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`, + JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)), + { models, lnd, hash, hmac, me, enforceFee } + ) await createMentions(item, models) diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index 8d78339f4..061c1d408 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -1,9 +1,8 @@ import { GraphQLError } from 'graphql' -import { settleHodlInvoice } from 'ln-service' import { amountSchema, ssValidate } from '../../lib/validate' -import serialize from './serial' +import { serializeInvoicable } from './serial' import { ANON_USER_ID } from '../../lib/constants' -import { getItem, checkInvoice } from './item' +import { getItem } from './item' export default { Query: { @@ -104,37 +103,12 @@ export default { }, Mutation: { donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => { - if (!me && !hash) { - throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } }) - } - let user - if (me) { - user = me - } - - let invoice - if (hash) { - invoice = await checkInvoice(models, hash, hmac, sats) - user = invoice.user - } - await ssValidate(amountSchema, { amount: sats }) - const trx = [ - models.$queryRawUnsafe( - 'SELECT donate($1::INTEGER, $2::INTEGER)', - sats, Number(user.id)) - ] - - if (invoice) { - trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${user.id}`) - trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })) - } - await serialize(models, ...trx) - - if (invoice?.isHeld) { - await settleHodlInvoice({ secret: invoice.preimage, lnd }) - } + await serializeInvoicable( + models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER)`, + { models, lnd, hash, hmac, me, enforceFee: sats } + ) return sats } diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index 1a4336bbd..aa93acf88 100644 --- a/api/resolvers/serial.js +++ b/api/resolvers/serial.js @@ -1,8 +1,11 @@ import { GraphQLError } from 'graphql' import retry from 'async-retry' import Prisma from '@prisma/client' +import { settleHodlInvoice } from 'ln-service' +import { createHmac } from './wallet' +import { msatsToSats } from '../../lib/format' -async function serialize (models, ...calls) { +export default async function serialize (models, ...calls) { return await retry(async bail => { try { const [, ...result] = await models.$transaction( @@ -56,4 +59,72 @@ async function serialize (models, ...calls) { }) } -export default serialize +export async function serializeInvoicable (query, { models, lnd, hash, hmac, me, enforceFee }) { + if (!me && !hash) { + throw new Error('you must be logged in or pay') + } + + let trx = [query] + + let invoice + if (hash) { + invoice = await checkInvoice(models, hash, hmac, enforceFee) + trx = [ + models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`, + query, + models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } }) + ] + } + + const results = await serialize(models, ...trx) + const result = trx.length > 1 ? results[1][0] : results[0] + + if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd }) + + return result +} + +export async function checkInvoice (models, hash, hmac, fee) { + if (!hash) { + throw new GraphQLError('hash required', { extensions: { code: 'BAD_INPUT' } }) + } + if (!hmac) { + throw new GraphQLError('hmac required', { extensions: { code: 'BAD_INPUT' } }) + } + const hmac2 = createHmac(hash) + if (hmac !== hmac2) { + throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) + } + + const invoice = await models.invoice.findUnique({ + where: { hash }, + include: { + user: true + } + }) + + if (!invoice) { + throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) + } + + const expired = new Date(invoice.expiresAt) <= new Date() + if (expired) { + throw new GraphQLError('invoice expired', { extensions: { code: 'BAD_INPUT' } }) + } + if (invoice.confirmedAt) { + throw new GraphQLError('invoice already used', { extensions: { code: 'BAD_INPUT' } }) + } + + if (invoice.cancelled) { + throw new GraphQLError('invoice was canceled', { extensions: { code: 'BAD_INPUT' } }) + } + + if (!invoice.msatsReceived) { + throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } }) + } + if (fee && msatsToSats(invoice.msatsReceived) < fee) { + throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } }) + } + + return invoice +}