diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 1a25b69a..19f8a751 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, { serializeInvoicable } from './serial' +import serialize 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 serializeInvoicable( + await serialize( models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)), - { me, models, lnd, hash, hmac } + { models, lnd, me, hash, hmac } ) return id @@ -883,7 +883,6 @@ export default { if (idempotent) { await serialize( - models, models.$queryRaw` SELECT item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, ${act}::"ItemActType", @@ -891,15 +890,16 @@ export default { FROM "ItemAct" WHERE act IN ('TIP', 'FEE') AND "itemId" = ${Number(id)}::INTEGER - AND "userId" = ${me.id}::INTEGER)::INTEGER)` + AND "userId" = ${me.id}::INTEGER)::INTEGER)`, + { models } ) } else { - await serializeInvoicable( + await serialize( models.$queryRaw` SELECT item_act(${Number(id)}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`, - { me, models, lnd, hash, hmac, enforceFee: sats } + { models, lnd, me, hash, hmac, fee: 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 serializeInvoicable( + item = await serialize( 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, hash, hmac, me, enforceFee: imgFees } + { models, lnd, me, hash, hmac, fee: 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 enforceFee = 0 + let fee = 0 if (!me) { if (item.parentId) { - enforceFee = ANON_FEE_MULTIPLIER + fee = ANON_FEE_MULTIPLIER } else { const sub = await models.sub.findUnique({ where: { name: item.subName } }) - enforceFee = sub.baseCost * ANON_FEE_MULTIPLIER + (item.boost || 0) + fee = sub.baseCost * ANON_FEE_MULTIPLIER + (item.boost || 0) } } - enforceFee += imgFees + fee += imgFees - item = await serializeInvoicable( + item = await serialize( 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, hash, hmac, me, enforceFee } + { models, lnd, me, hash, hmac, fee } ) await createMentions(item, models) diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index 7c96d1a2..19450237 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 { serializeInvoicable } from './serial' +import serialize 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 serializeInvoicable( + await serialize( models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER)`, - { models, lnd, hash, hmac, me, enforceFee: sats } + { models, lnd, me, hash, hmac, fee: sats } ) return sats diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index 62bb6a59..d7bce05c 100644 --- a/api/resolvers/serial.js +++ b/api/resolvers/serial.js @@ -1,4 +1,5 @@ import { GraphQLError } from 'graphql' +import { timingSafeEqual } from 'crypto' import retry from 'async-retry' import Prisma from '@prisma/client' import { settleHodlInvoice } from 'ln-service' @@ -6,13 +7,30 @@ import { createHmac } from './wallet' import { msatsToSats, numWithUnits } from '@/lib/format' import { BALANCE_LIMIT_MSATS } from '@/lib/constants' -export default async function serialize (models, ...calls) { - return await retry(async bail => { +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 => { try { - const [, ...result] = await models.$transaction( - [models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...calls], + const [, ...results] = await models.$transaction( + [models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...trx], { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) - return calls.length > 1 ? result : result[0] + return results } catch (error) { console.log(error) // two cases where we get insufficient funds: @@ -64,38 +82,20 @@ export default async function serialize (models, ...calls) { maxTimeout: 100, retries: 10 }) -} - -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 }) } + if (invoice?.isHeld) { + await settleHodlInvoice({ secret: invoice.preimage, lnd }) + } // remove first element since that is the confirmed invoice - [, ...results] = results + results = results.slice(1) } - // if there is only one result, return it directly, else the array - results = results.flat(2) - return results.length > 1 ? results : results[0] + // if first argument was not an array, unwrap the result + return isArray ? results : results[0] } -export async function checkInvoice (models, hash, hmac, fee) { +async function verifyPayment (models, hash, hmac, fee) { if (!hash) { throw new GraphQLError('hash required', { extensions: { code: 'BAD_INPUT' } }) } @@ -103,7 +103,7 @@ export async function checkInvoice (models, hash, hmac, fee) { throw new GraphQLError('hmac required', { extensions: { code: 'BAD_INPUT' } }) } const hmac2 = createHmac(hash) - if (hmac !== hmac2) { + if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) { throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) } diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index af25f7dc..15c19088 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -1,5 +1,5 @@ import { GraphQLError } from 'graphql' -import { serializeInvoicable } from './serial' +import serialize 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 serializeInvoicable( + const results = await serialize( queries, - { models, lnd, hash, hmac, me, enforceFee: sub.billingCost }) + { models, lnd, me, hash, hmac, fee: sub.billingCost }) return results[1] }, toggleMuteSub: async (parent, { name }, { me, models }) => { @@ -344,8 +344,9 @@ 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 serializeInvoicable([ + await serialize([ models.user.update({ where: { id: me.id @@ -365,11 +366,11 @@ export default { } }), models.sub.update({ where: { name }, data: newSub }), - 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 }) + isTransfer && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } }) + ], + { models, lnd, hash, me, hmac, fee: billingCost }) - if (oldSub.userId !== me.id) notifyTerritoryTransfer({ models, sub: newSub, to: me }) + if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me }) } }, Sub: { @@ -417,7 +418,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) { const cost = BigInt(1000) * BigInt(billingCost) try { - const results = await serializeInvoicable([ + const results = await serialize([ // bill 'em models.user.update({ where: { @@ -456,7 +457,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) { subName: data.name } }) - ], { models, lnd, hash, hmac, me, enforceFee: billingCost }) + ], { models, lnd, me, hash, hmac, fee: billingCost }) return results[1] } catch (error) { @@ -511,7 +512,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 serializeInvoicable([ + const results = await serialize([ models.user.update({ where: { id: me.id @@ -537,7 +538,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash, userId: me.id } }) - ], { models, lnd, hash, hmac, me, enforceFee: proratedCost }) + ], { models, lnd, me, hash, hmac, fee: proratedCost }) return results[2] } } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index b6e22c77..ccfbe8f8 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -354,10 +354,12 @@ export default { expires_at: expiresAt }) - const [inv] = await serialize(models, + const [inv] = await serialize( 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})`) + ${invLimit}::INTEGER, ${balanceLimit})`, + { models } + ) // the HMAC is only returned during invoice creation // this makes sure that only the person who created this invoice @@ -378,7 +380,7 @@ export default { throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) } await cancelHodlInvoice({ id: hash, lnd }) - const inv = await serialize(models, + const inv = await serialize( models.invoice.update({ where: { hash @@ -386,7 +388,9 @@ export default { data: { cancelled: true } - })) + }), + { models } + ) return inv }, dropBolt11: async (parent, { id }, { me, models, lnd }) => { @@ -660,9 +664,11 @@ 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(models, + const [withdrawl] = await serialize( models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice}, - ${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw})`) + ${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw})`, + { models } + ) payViaPaymentRequest({ lnd, diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 6361ee66..c4b3c411 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -80,11 +80,13 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa expires_at: expiresAt }) - await serialize(models, + await serialize( 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})`) + ${USER_IDS_BALANCE_NO_LIMIT.includes(Number(user.id)) ? 0 : BALANCE_LIMIT_MSATS})`, + { models } + ) return res.status(200).json({ pr: invoice.request, diff --git a/pages/invites/[id].js b/pages/invites/[id].js index e7516249..98672520 100644 --- a/pages/invites/[id].js +++ b/pages/invites/[id].js @@ -36,8 +36,10 @@ 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, - models.$queryRawUnsafe('SELECT invite_drain($1::INTEGER, $2::TEXT)', session.user.id, id)) + await serialize( + models.$queryRawUnsafe('SELECT invite_drain($1::INTEGER, $2::TEXT)', session.user.id, id), + { models } + ) const invite = await models.invite.findUnique({ where: { id } }) notifyInvite(invite.userId) } catch (e) { diff --git a/worker/auction.js b/worker/auction.js index 901c7859..602adbfa 100644 --- a/worker/auction.js +++ b/worker/auction.js @@ -17,7 +17,6 @@ export async function auction ({ models }) { // for each item, run serialized auction function items.forEach(async item => { - await serialize(models, - models.$executeRaw`SELECT run_auction(${item.id}::INTEGER)`) + await serialize(models.$executeRaw`SELECT run_auction(${item.id}::INTEGER)`, { models }) }) } diff --git a/worker/earn.js b/worker/earn.js index 571369bc..bb1c9b1f 100644 --- a/worker/earn.js +++ b/worker/earn.js @@ -79,9 +79,11 @@ 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(models, + await serialize( models.$executeRaw`SELECT earn(${earner.userId}::INTEGER, ${earnings}, - ${now}::timestamp without time zone, ${earner.type}::"EarnType", ${earner.id}::INTEGER, ${earner.rank}::INTEGER)`) + ${now}::timestamp without time zone, ${earner.type}::"EarnType", ${earner.id}::INTEGER, ${earner.rank}::INTEGER)`, + { models } + ) const userN = notifications[earner.userId] || {} diff --git a/worker/territory.js b/worker/territory.js index cc453ae9..178bbf3b 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(models, ...queries) + await serialize(queries, { models }) } 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(models, + await serialize( models.$executeRaw` WITH revenue AS ( SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId" @@ -69,6 +69,7 @@ export async function territoryRevenue ({ models }) { ) UPDATE users SET msats = users.msats + "SubActResult".msats FROM "SubActResult" - WHERE users.id = "SubActResult"."userId"` + WHERE users.id = "SubActResult"."userId"`, + { models } ) } diff --git a/worker/wallet.js b/worker/wallet.js index b91337ae..2bfc6f40 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(models, + const [[{ confirm_invoice: code }]] = await serialize([ 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(models, + return await serialize([ models.$queryRaw` INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) VALUES ('finalizeHodlInvoice', jsonb_build_object('hash', ${hash}), 21, true, ${expiresAt})`, @@ -154,11 +154,12 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) { expiresAt, isHeld: true } - })) + }) + ], { models }) } if (inv.is_canceled) { - return await serialize(models, + return await serialize( models.invoice.update({ where: { hash: inv.id @@ -166,7 +167,8 @@ async function checkInvoice ({ data: { hash }, boss, models, lnd }) { data: { cancelled: true } - })) + }), { models } + ) } } @@ -228,8 +230,10 @@ 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, models.$queryRaw` - SELECT confirm_withdrawl(${dbWdrwl.id}::INTEGER, ${paid}, ${fee})`) + const [{ confirm_withdrawl: code }] = await serialize( + models.$queryRaw`SELECT confirm_withdrawl(${dbWdrwl.id}::INTEGER, ${paid}, ${fee})`, + { models } + ) if (code === 0) { notifyWithdrawal(dbWdrwl.userId, wdrwl) } @@ -245,9 +249,10 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) { status = 'ROUTE_NOT_FOUND' } - await serialize(models, + await serialize( models.$executeRaw` - SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")` + SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`, + { models } ) } }