From 6ba1c3e8ab3a4793ec035f37d326a20c370ea117 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 19:38:06 -0500 Subject: [PATCH] anon func mods, e.g. inv limits --- api/resolvers/item.js | 10 +- api/resolvers/wallet.js | 21 ++- lib/constants.js | 5 + pages/api/lnurlp/[username]/pay.js | 6 +- .../migration.sql | 144 ++++++++++++++++++ 5 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20230810234326_anon_func_exemptions/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index cb3213a0..7ab504ab 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -8,7 +8,7 @@ import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, - ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE + ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE, ANON_ITEM_SPAM_INTERVAL } from '../../lib/constants' import { msatsToSats, numWithUnits } from '../../lib/format' import { parse } from 'tldts' @@ -627,10 +627,12 @@ export default { upsertPoll: async (parent, { id, ...data }, { me, models }) => { const { sub, forward, boost, title, text, options, invoiceHash, invoiceHmac } = data let author = me + let spamInterval = ITEM_SPAM_INTERVAL const trx = [] if (!me && invoiceHash) { const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, ANON_POST_FEE) author = invoice.user + spamInterval = ANON_ITEM_SPAM_INTERVAL trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } @@ -670,7 +672,7 @@ export default { return item } else { const [query] = await serialize(models, - models.$queryRawUnsafe(`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`, + models.$queryRawUnsafe(`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${spamInterval}') AS "Item"`, sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, Number(fwdUser?.id)), ...trx) const item = trx.length > 0 ? query[0] : query @@ -1107,10 +1109,12 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash, invoiceHmac }) => { let author = me + let spamInterval = ITEM_SPAM_INTERVAL const trx = [] if (!me && invoiceHash) { const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) author = invoice.user + spamInterval = ANON_ITEM_SPAM_INTERVAL trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } @@ -1140,7 +1144,7 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount const [query] = await serialize( models, models.$queryRawUnsafe( - `${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`, + `${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${spamInterval}') AS "Item"`, parentId ? null : sub || 'bitcoin', title, url, diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index b5c405fe..e48b3b5e 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -8,7 +8,7 @@ import { SELECT } from './item' import { lnurlPayDescriptionHash } from '../../lib/lnurl' import { msatsToSats, msatsToSatsDecimal } from '../../lib/format' import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../lib/validate' -import { ANON_USER_ID } from '../../lib/constants' +import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT } from '../../lib/constants' import { datePivot } from '../../lib/time' export async function getInvoice (parent, { id }, { me, models }) { @@ -210,10 +210,20 @@ export default { createInvoice: async (parent, { amount }, { me, models, lnd }) => { await ssValidate(amountSchema, { amount }) - const user = await models.user.findUnique({ where: { id: me ? me.id : ANON_USER_ID } }) - const pivot = me ? { hours: 3 } : { minutes: 3 } + let expirePivot = { hours: 3 } + let invLimit = INV_PENDING_LIMIT + let balanceLimit = BALANCE_LIMIT_MSATS + let id = me?.id + if (!me) { + expirePivot = { minutes: 3 } + invLimit = ANON_INV_PENDING_LIMIT + balanceLimit = ANON_BALANCE_LIMIT_MSATS + id = ANON_USER_ID + } - const expiresAt = datePivot(new Date(), pivot) + const user = await models.user.findUnique({ where: { id } }) + + const expiresAt = datePivot(new Date(), expirePivot) const description = `Funding @${user.name} on stacker.news` try { const invoice = await createInvoice({ @@ -225,7 +235,8 @@ export default { const [inv] = await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, - ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description})`) + ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, + ${invLimit}::INTEGER, ${balanceLimit})`) // the HMAC is only returned during invoice creation // this makes sure that only the person who created this invoice diff --git a/lib/constants.js b/lib/constants.js index d8d9d8e3..54df2169 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -19,6 +19,11 @@ module.exports = { MAX_TITLE_LENGTH: 80, MAX_POLL_CHOICE_LENGTH: 30, ITEM_SPAM_INTERVAL: '10m', + ANON_ITEM_SPAM_INTERVAL: '0', + INV_PENDING_LIMIT: 10, + BALANCE_LIMIT_MSATS: 1000000000, // 1m sats + ANON_INV_PENDING_LIMIT: 100, + ANON_BALANCE_LIMIT_MSATS: 100000000000, // 100m sats MAX_POLL_NUM_CHOICES: 10, MIN_POLL_NUM_CHOICES: 2, ITEM_FILTER_THRESHOLD: 1.2, diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 7e792ca2..9571b0e4 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -6,6 +6,7 @@ import serialize from '../../../../api/resolvers/serial' import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' import { datePivot } from '../../../../lib/time' +import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT } from '../../../../lib/constants' export default async ({ query: { username, amount, nostr } }, res) => { const user = await models.user.findUnique({ where: { name: username } }) @@ -13,7 +14,7 @@ export default async ({ query: { username, amount, nostr } }, res) => { return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` }) } try { - // if nostr, decode, validate sig, check tags, set description hash + // if nostr, decode, validate sig, check tags, set description hash let description, descriptionHash, noteStr if (nostr) { noteStr = decodeURIComponent(nostr) @@ -48,7 +49,8 @@ export default async ({ query: { username, amount, nostr } }, res) => { await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, - ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description})`) + ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description}, + ${INV_PENDING_LIMIT}::INTEGER, ${BALANCE_LIMIT_MSATS})`) return res.status(200).json({ pr: invoice.request, diff --git a/prisma/migrations/20230810234326_anon_func_exemptions/migration.sql b/prisma/migrations/20230810234326_anon_func_exemptions/migration.sql new file mode 100644 index 00000000..9df4bd53 --- /dev/null +++ b/prisma/migrations/20230810234326_anon_func_exemptions/migration.sql @@ -0,0 +1,144 @@ +DROP FUNCTION IF EXISTS create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, msats_req BIGINT, user_id INTEGER, idesc TEXT); +-- make invoice limit and balance limit configurable +CREATE OR REPLACE FUNCTION create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, + msats_req BIGINT, user_id INTEGER, idesc TEXT, inv_limit INTEGER, balance_limit_msats BIGINT) +RETURNS "Invoice" +LANGUAGE plpgsql +AS $$ +DECLARE + invoice "Invoice"; + inv_limit_reached BOOLEAN; + balance_limit_reached BOOLEAN; + inv_pending_msats BIGINT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + -- prevent too many pending invoices + SELECT inv_limit > 0 AND count(*) >= inv_limit, sum("msatsRequested") INTO inv_limit_reached, inv_pending_msats + FROM "Invoice" + WHERE "userId" = user_id AND "expiresAt" > now_utc() AND "confirmedAt" IS NULL AND cancelled = false; + + IF inv_limit_reached THEN + RAISE EXCEPTION 'SN_INV_PENDING_LIMIT'; + END IF; + + -- prevent pending invoices + msats from exceeding the limit + SELECT balance_limit_msats > 0 AND inv_pending_msats+msats_req+msats > balance_limit_msats INTO balance_limit_reached + FROM users + WHERE id = user_id; + + IF balance_limit_reached THEN + RAISE EXCEPTION 'SN_INV_EXCEED_BALANCE'; + END IF; + + -- we good, proceed frens + INSERT INTO "Invoice" (hash, bolt11, "expiresAt", "msatsRequested", "userId", created_at, updated_at, "desc") + VALUES (hash, bolt11, expires_at, msats_req, user_id, now_utc(), now_utc(), idesc) RETURNING * INTO invoice; + + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('checkInvoice', jsonb_build_object('hash', hash), 21, true, now() + interval '10 seconds'); + + RETURN invoice; +END; +$$; + +-- don't presume outlaw status for anon posters +CREATE OR REPLACE FUNCTION create_item( + sub TEXT, title TEXT, url TEXT, text TEXT, boost INTEGER, bounty INTEGER, + parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER, + spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats BIGINT; + cost_msats BIGINT; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT msats INTO user_msats FROM users WHERE id = user_id; + + cost_msats := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)); + -- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0 + freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (boost = 0); + + IF NOT freebie AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + INTO med_votes FROM "Item" WHERE "userId" = user_id; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + -- addendum: if they're an anon poster, always start at 0 + IF med_votes >= 0 OR user_id = 27 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + INSERT INTO "Item" + ("subName", title, url, text, bounty, "userId", "parentId", "fwdUserId", + freebie, "weightedDownVotes", created_at, updated_at) + VALUES + (sub, title, url, text, bounty, user_id, parent_id, fwd_user_id, + freebie, med_votes, now_utc(), now_utc()) RETURNING * INTO item; + + IF NOT freebie THEN + UPDATE users SET msats = msats - cost_msats WHERE id = user_id; + + INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at) + VALUES (cost_msats, item.id, user_id, 'FEE', now_utc(), now_utc()); + END IF; + + IF boost > 0 THEN + PERFORM item_act(item.id, user_id, 'BOOST', boost); + END IF; + + RETURN item; +END; +$$; + +-- keep item_spam unaware of anon user +CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + repeats INTEGER; + self_replies INTEGER; +BEGIN + -- no fee escalation + IF within = interval '0' THEN + RETURN 0; + END IF; + + SELECT count(*) INTO repeats + FROM "Item" + WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id) + AND "userId" = user_id + AND created_at > now_utc() - within; + + IF parent_id IS NULL THEN + RETURN repeats; + END IF; + + WITH RECURSIVE base AS ( + SELECT "Item".id, "Item"."parentId", "Item"."userId" + FROM "Item" + WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within + UNION ALL + SELECT "Item".id, "Item"."parentId", "Item"."userId" + FROM base p + JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within) + SELECT count(*) INTO self_replies FROM base; + + RETURN repeats + self_replies; +END; +$$; \ No newline at end of file