diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 19f8a751..1a25b69a 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1,6 +1,6 @@ import { GraphQLError } from 'graphql' import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url' -import serialize from './serial' +import serialize, { serializeInvoicable } from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper' @@ -849,9 +849,9 @@ export default { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } - await serialize( + await serializeInvoicable( models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)), - { models, lnd, me, hash, hmac } + { me, models, lnd, hash, hmac } ) return id @@ -883,6 +883,7 @@ export default { if (idempotent) { await serialize( + models, models.$queryRaw` SELECT item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, ${act}::"ItemActType", @@ -890,16 +891,15 @@ export default { FROM "ItemAct" WHERE act IN ('TIP', 'FEE') AND "itemId" = ${Number(id)}::INTEGER - AND "userId" = ${me.id}::INTEGER)::INTEGER)`, - { models } + AND "userId" = ${me.id}::INTEGER)::INTEGER)` ) } else { - await serialize( + await serializeInvoicable( models.$queryRaw` SELECT item_act(${Number(id)}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`, - { models, lnd, me, hash, hmac, fee: sats } + { me, models, lnd, hash, hmac, enforceFee: sats } ) } @@ -1284,10 +1284,10 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it const uploadIds = uploadIdsFromText(item.text, { models }) const { totalFees: imgFees } = await imageFeesInfo(uploadIds, { models, me }) - item = await serialize( + item = await serializeInvoicable( models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`, JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds), - { models, lnd, me, hash, hmac, fee: imgFees } + { models, lnd, hash, hmac, me, enforceFee: imgFees } ) await createMentions(item, models) @@ -1320,22 +1320,22 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo const uploadIds = uploadIdsFromText(item.text, { models }) const { totalFees: imgFees } = await imageFeesInfo(uploadIds, { models, me }) - let fee = 0 + let enforceFee = 0 if (!me) { if (item.parentId) { - fee = ANON_FEE_MULTIPLIER + enforceFee = ANON_FEE_MULTIPLIER } else { const sub = await models.sub.findUnique({ where: { name: item.subName } }) - fee = sub.baseCost * ANON_FEE_MULTIPLIER + (item.boost || 0) + enforceFee = sub.baseCost * ANON_FEE_MULTIPLIER + (item.boost || 0) } } - fee += imgFees + enforceFee += imgFees - item = await serialize( + item = await serializeInvoicable( models.$queryRawUnsafe( `${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`, JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds), - { models, lnd, me, hash, hmac, fee } + { models, lnd, hash, hmac, me, enforceFee } ) await createMentions(item, models) diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index 19450237..7c96d1a2 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -1,6 +1,6 @@ import { GraphQLError } from 'graphql' import { amountSchema, ssValidate } from '@/lib/validate' -import serialize from './serial' +import { serializeInvoicable } from './serial' import { ANON_USER_ID } from '@/lib/constants' import { getItem } from './item' import { topUsers } from './user' @@ -168,9 +168,9 @@ export default { donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => { await ssValidate(amountSchema, { amount: sats }) - await serialize( + await serializeInvoicable( models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER)`, - { models, lnd, me, hash, hmac, fee: sats } + { models, lnd, hash, hmac, me, enforceFee: sats } ) return sats diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index d7bce05c..62bb6a59 100644 --- a/api/resolvers/serial.js +++ b/api/resolvers/serial.js @@ -1,5 +1,4 @@ import { GraphQLError } from 'graphql' -import { timingSafeEqual } from 'crypto' import retry from 'async-retry' import Prisma from '@prisma/client' import { settleHodlInvoice } from 'ln-service' @@ -7,30 +6,13 @@ import { createHmac } from './wallet' import { msatsToSats, numWithUnits } from '@/lib/format' import { BALANCE_LIMIT_MSATS } from '@/lib/constants' -export default async function serialize (trx, { models, lnd, me, hash, hmac, fee }) { - // wrap first argument in array if not array already - const isArray = Array.isArray(trx) - if (!isArray) trx = [trx] - - // conditional queries can be added inline using && syntax - // we filter any falsy value out here - trx = trx.filter(q => !!q) - - let invoice - if (hash) { - invoice = await verifyPayment(models, hash, hmac, fee) - trx = [ - models.$executeRaw`SELECT confirm_invoice(${hash}, ${invoice.msatsReceived})`, - ...trx - ] - } - - let results = await retry(async bail => { +export default async function serialize (models, ...calls) { + return await retry(async bail => { try { - const [, ...results] = await models.$transaction( - [models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...trx], + const [, ...result] = await models.$transaction( + [models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...calls], { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) - return results + return calls.length > 1 ? result : result[0] } catch (error) { console.log(error) // two cases where we get insufficient funds: @@ -82,20 +64,38 @@ export default async function serialize (trx, { models, lnd, me, hash, hmac, fee maxTimeout: 100, retries: 10 }) - - if (hash) { - if (invoice?.isHeld) { - await settleHodlInvoice({ secret: invoice.preimage, lnd }) - } - // remove first element since that is the confirmed invoice - results = results.slice(1) - } - - // if first argument was not an array, unwrap the result - return isArray ? results : results[0] } -async function verifyPayment (models, hash, hmac, fee) { +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 = Array.isArray(query) ? query : [query] + + let invoice + if (hash) { + invoice = await checkInvoice(models, hash, hmac, enforceFee) + trx = [ + models.$executeRaw`SELECT confirm_invoice(${hash}, ${invoice.msatsReceived})`, + ...trx + ] + } + + let results = await serialize(models, ...trx) + + if (hash) { + if (invoice?.isHeld) { await settleHodlInvoice({ secret: invoice.preimage, lnd }) } + // remove first element since that is the confirmed invoice + [, ...results] = results + } + + // if there is only one result, return it directly, else the array + results = results.flat(2) + return results.length > 1 ? results : results[0] +} + +export async function checkInvoice (models, hash, hmac, fee) { if (!hash) { throw new GraphQLError('hash required', { extensions: { code: 'BAD_INPUT' } }) } @@ -103,7 +103,7 @@ async function verifyPayment (models, hash, hmac, fee) { throw new GraphQLError('hmac required', { extensions: { code: 'BAD_INPUT' } }) } const hmac2 = createHmac(hash) - if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) { + if (hmac !== hmac2) { throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) } diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 15c19088..af25f7dc 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -1,5 +1,5 @@ import { GraphQLError } from 'graphql' -import serialize from './serial' +import { serializeInvoicable } from './serial' import { TERRITORY_COST_MONTHLY, TERRITORY_COST_ONCE, TERRITORY_COST_YEARLY, TERRITORY_PERIOD_COST } from '@/lib/constants' import { datePivot, whenRange } from '@/lib/time' import { ssValidate, territorySchema } from '@/lib/validate' @@ -246,9 +246,9 @@ export default { return sub } - const results = await serialize( + const results = await serializeInvoicable( queries, - { models, lnd, me, hash, hmac, fee: sub.billingCost }) + { models, lnd, hash, hmac, me, enforceFee: sub.billingCost }) return results[1] }, toggleMuteSub: async (parent, { name }, { me, models }) => { @@ -344,9 +344,8 @@ export default { const billPaidUntil = nextBilling(new Date(), data.billingType) const cost = BigInt(1000) * BigInt(billingCost) const newSub = { ...data, billPaidUntil, billingCost, userId: me.id, status: 'ACTIVE' } - const isTransfer = oldSub.userId !== me.id - await serialize([ + await serializeInvoicable([ models.user.update({ where: { id: me.id @@ -366,11 +365,11 @@ export default { } }), models.sub.update({ where: { name }, data: newSub }), - isTransfer && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } }) - ], - { models, lnd, hash, me, hmac, fee: billingCost }) + oldSub.userId !== me.id && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } }) + ].filter(q => !!q), + { models, lnd, hash, hmac, me, enforceFee: billingCost }) - if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me }) + if (oldSub.userId !== me.id) notifyTerritoryTransfer({ models, sub: newSub, to: me }) } }, Sub: { @@ -418,7 +417,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) { const cost = BigInt(1000) * BigInt(billingCost) try { - const results = await serialize([ + const results = await serializeInvoicable([ // bill 'em models.user.update({ where: { @@ -457,7 +456,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) { subName: data.name } }) - ], { models, lnd, me, hash, hmac, fee: billingCost }) + ], { models, lnd, hash, hmac, me, enforceFee: billingCost }) return results[1] } catch (error) { @@ -512,7 +511,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash, const proratedCost = proratedBillingCost(oldSub, data.billingType) if (proratedCost > 0) { const cost = BigInt(1000) * BigInt(proratedCost) - const results = await serialize([ + const results = await serializeInvoicable([ models.user.update({ where: { id: me.id @@ -538,7 +537,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash, userId: me.id } }) - ], { models, lnd, me, hash, hmac, fee: proratedCost }) + ], { models, lnd, hash, hmac, me, enforceFee: proratedCost }) return results[2] } } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index ccfbe8f8..b6e22c77 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -354,12 +354,10 @@ export default { expires_at: expiresAt }) - const [inv] = await serialize( + const [inv] = await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${hodlInvoice ? invoice.secret : null}::TEXT, ${invoice.request}, ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL, - ${invLimit}::INTEGER, ${balanceLimit})`, - { models } - ) + ${invLimit}::INTEGER, ${balanceLimit})`) // the HMAC is only returned during invoice creation // this makes sure that only the person who created this invoice @@ -380,7 +378,7 @@ export default { throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) } await cancelHodlInvoice({ id: hash, lnd }) - const inv = await serialize( + const inv = await serialize(models, models.invoice.update({ where: { hash @@ -388,9 +386,7 @@ export default { data: { cancelled: true } - }), - { models } - ) + })) return inv }, dropBolt11: async (parent, { id }, { me, models, lnd }) => { @@ -664,11 +660,9 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model const user = await models.user.findUnique({ where: { id: me.id } }) // create withdrawl transactionally (id, bolt11, amount, fee) - const [withdrawl] = await serialize( + const [withdrawl] = await serialize(models, models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice}, - ${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw})`, - { models } - ) + ${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw})`) payViaPaymentRequest({ lnd, diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index c4b3c411..6361ee66 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -80,13 +80,11 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa expires_at: expiresAt }) - await serialize( + await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, NULL, ${invoice.request}, ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description}, ${comment || null}, ${parsedPayerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER, - ${USER_IDS_BALANCE_NO_LIMIT.includes(Number(user.id)) ? 0 : BALANCE_LIMIT_MSATS})`, - { models } - ) + ${USER_IDS_BALANCE_NO_LIMIT.includes(Number(user.id)) ? 0 : BALANCE_LIMIT_MSATS})`) return res.status(200).json({ pr: invoice.request, diff --git a/pages/invites/[id].js b/pages/invites/[id].js index 98672520..e7516249 100644 --- a/pages/invites/[id].js +++ b/pages/invites/[id].js @@ -36,10 +36,8 @@ export async function getServerSideProps ({ req, res, query: { id, error = null try { // attempt to send gift // catch any errors and just ignore them for now - await serialize( - models.$queryRawUnsafe('SELECT invite_drain($1::INTEGER, $2::TEXT)', session.user.id, id), - { models } - ) + await serialize(models, + models.$queryRawUnsafe('SELECT invite_drain($1::INTEGER, $2::TEXT)', session.user.id, id)) const invite = await models.invite.findUnique({ where: { id } }) notifyInvite(invite.userId) } catch (e) { diff --git a/worker/auction.js b/worker/auction.js index 602adbfa..901c7859 100644 --- a/worker/auction.js +++ b/worker/auction.js @@ -17,6 +17,7 @@ export async function auction ({ models }) { // for each item, run serialized auction function items.forEach(async item => { - await serialize(models.$executeRaw`SELECT run_auction(${item.id}::INTEGER)`, { models }) + await serialize(models, + models.$executeRaw`SELECT run_auction(${item.id}::INTEGER)`) }) } diff --git a/worker/earn.js b/worker/earn.js index bb1c9b1f..571369bc 100644 --- a/worker/earn.js +++ b/worker/earn.js @@ -79,11 +79,9 @@ export async function earn ({ name }) { console.log('stacker', earner.userId, 'earned', earnings, 'proportion', earner.proportion, 'rank', earner.rank, 'type', earner.type) if (earnings > 0) { - await serialize( + await serialize(models, models.$executeRaw`SELECT earn(${earner.userId}::INTEGER, ${earnings}, - ${now}::timestamp without time zone, ${earner.type}::"EarnType", ${earner.id}::INTEGER, ${earner.rank}::INTEGER)`, - { models } - ) + ${now}::timestamp without time zone, ${earner.type}::"EarnType", ${earner.id}::INTEGER, ${earner.rank}::INTEGER)`) const userN = notifications[earner.userId] || {} diff --git a/worker/territory.js b/worker/territory.js index 178bbf3b..cc453ae9 100644 --- a/worker/territory.js +++ b/worker/territory.js @@ -34,7 +34,7 @@ export async function territoryBilling ({ data: { subName }, boss, models }) { try { const queries = paySubQueries(sub, models) - await serialize(queries, { models }) + await serialize(models, ...queries) } catch (e) { console.error(e) await territoryStatusUpdate() @@ -42,7 +42,7 @@ export async function territoryBilling ({ data: { subName }, boss, models }) { } export async function territoryRevenue ({ models }) { - await serialize( + await serialize(models, models.$executeRaw` WITH revenue AS ( SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId" @@ -69,7 +69,6 @@ export async function territoryRevenue ({ models }) { ) UPDATE users SET msats = users.msats + "SubActResult".msats FROM "SubActResult" - WHERE users.id = "SubActResult"."userId"`, - { models } + WHERE users.id = "SubActResult"."userId"` ) } diff --git a/worker/wallet.js b/worker/wallet.js index 2bfc6f40..b91337ae 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -121,10 +121,10 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) { // ALSO: is_confirmed and is_held are mutually exclusive // that is, a hold invoice will first be is_held but not is_confirmed // and once it's settled it will be is_confirmed but not is_held - const [[{ confirm_invoice: code }]] = await serialize([ + const [[{ confirm_invoice: code }]] = await serialize(models, models.$queryRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`, models.invoice.update({ where: { hash }, data: { confirmedIndex: inv.confirmed_index } }) - ], { models }) + ) // don't send notifications for JIT invoices if (dbInv.preimage) return @@ -143,7 +143,7 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) { // and without setting the user balance // those will be set when the invoice is settled by user action const expiresAt = new Date(Math.min(dbInv.expiresAt, datePivot(new Date(), { seconds: 60 }))) - return await serialize([ + return await serialize(models, models.$queryRaw` INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) VALUES ('finalizeHodlInvoice', jsonb_build_object('hash', ${hash}), 21, true, ${expiresAt})`, @@ -154,12 +154,11 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) { expiresAt, isHeld: true } - }) - ], { models }) + })) } if (inv.is_canceled) { - return await serialize( + return await serialize(models, models.invoice.update({ where: { hash: inv.id @@ -167,8 +166,7 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) { data: { cancelled: true } - }), { models } - ) + })) } } @@ -230,10 +228,8 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) { if (wdrwl?.is_confirmed) { const fee = Number(wdrwl.payment.fee_mtokens) const paid = Number(wdrwl.payment.mtokens) - fee - const [{ confirm_withdrawl: code }] = await serialize( - models.$queryRaw`SELECT confirm_withdrawl(${dbWdrwl.id}::INTEGER, ${paid}, ${fee})`, - { models } - ) + const [{ confirm_withdrawl: code }] = await serialize(models, models.$queryRaw` + SELECT confirm_withdrawl(${dbWdrwl.id}::INTEGER, ${paid}, ${fee})`) if (code === 0) { notifyWithdrawal(dbWdrwl.userId, wdrwl) } @@ -249,10 +245,9 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) { status = 'ROUTE_NOT_FOUND' } - await serialize( + await serialize(models, models.$executeRaw` - SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`, - { models } + SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")` ) } }