From 5415c6b0f6133121b32057d4c1347b3e56569b72 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 13 Jul 2023 05:08:32 +0200 Subject: [PATCH 01/54] Add anon zaps --- api/resolvers/item.js | 22 ++++- api/resolvers/serial.js | 8 +- api/resolvers/wallet.js | 22 ++--- api/typeDefs/item.js | 2 +- components/invoice.js | 5 +- components/item-act.js | 34 +++++--- components/item-info.js | 8 +- components/upvote.js | 44 +++++----- fragments/comments.js | 1 + fragments/items.js | 1 + lib/anonymous.js | 83 +++++++++++++++++++ lib/apollo.js | 11 +++ lib/constants.js | 2 + pages/invoices/[id].js | 2 +- .../20230719195700_anon_update/migration.sql | 1 + 15 files changed, 190 insertions(+), 56 deletions(-) create mode 100644 lib/anonymous.js create mode 100644 prisma/migrations/20230719195700_anon_update/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index c0aa5bfe4..cb20b05ae 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -16,6 +16,7 @@ import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, import { sendUserNotification } from '../webPush' import { proxyImages } from './imgproxy' import { defaultCommentSort } from '../../lib/item' +import { checkInvoice } from '../../lib/anonymous' export async function commentFilterClause (me, models) { let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` @@ -711,24 +712,37 @@ export default { return id }, - act: async (parent, { id, sats }, { me, models }) => { + act: async (parent, { id, sats, invoiceId }, { me, models }) => { // need to make sure we are logged in - if (!me) { + if (!me && !invoiceId) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } await ssValidate(amountSchema, { amount: sats }) + let user = me + if (!me && invoiceId) { + const invoice = await checkInvoice(models, invoiceId, sats) + user = invoice.user + } + // disallow self tips const [item] = await models.$queryRawUnsafe(` ${SELECT} FROM "Item" - WHERE id = $1 AND "userId" = $2`, Number(id), me.id) + WHERE id = $1 AND "userId" = $2`, Number(id), user.id) if (item) { throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } }) } - const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`) + const calls = [ + models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` + ] + if (!me && invoiceId) { + calls.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + } + + const [{ item_act: vote }] = await serialize(models, ...calls) const updatedItem = await models.item.findUnique({ where: { id: Number(id) } }) const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${Math.floor(Number(updatedItem.msats) / 1000)} sats${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}` diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index ec7d6bcbc..5edd2c2e7 100644 --- a/api/resolvers/serial.js +++ b/api/resolvers/serial.js @@ -2,13 +2,13 @@ const { GraphQLError } = require('graphql') const retry = require('async-retry') const Prisma = require('@prisma/client') -async function serialize (models, call) { +async function serialize (models, ...calls) { return await retry(async bail => { try { - const [, result] = await models.$transaction( - [models.$executeRaw`SELECT ASSERT_SERIALIZED()`, call], + const [, ...result] = await models.$transaction( + [models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...calls], { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) - return result + return calls.length > 1 ? result : result[0] } catch (error) { console.log(error) if (error.message.includes('SN_INSUFFICIENT_FUNDS')) { diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index b4878aa66..08d716bd7 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -7,12 +7,9 @@ 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' export async function getInvoice (parent, { id }, { me, models }) { - if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) - } - const inv = await models.invoice.findUnique({ where: { id: Number(id) @@ -22,6 +19,15 @@ export async function getInvoice (parent, { id }, { me, models }) { } }) + if (!inv) { + throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) + } + if (inv.user.id === ANON_USER_ID) { + return inv + } + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + } if (inv.user.id !== me.id) { throw new GraphQLError('not ur invoice', { extensions: { code: 'FORBIDDEN' } }) } @@ -190,13 +196,9 @@ export default { Mutation: { createInvoice: async (parent, { amount }, { me, models, lnd }) => { - if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) - } - await ssValidate(amountSchema, { amount }) - const user = await models.user.findUnique({ where: { id: me.id } }) + const user = await models.user.findUnique({ where: { id: me ? me.id : ANON_USER_ID } }) // set expires at to 3 hours into future const expiresAt = new Date(new Date().setHours(new Date().getHours() + 3)) @@ -211,7 +213,7 @@ export default { const [inv] = await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, - ${expiresAt}, ${amount * 1000}, ${me.id}::INTEGER, ${description})`) + ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description})`) return inv } catch (error) { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 60b5e09f3..7962d3c89 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -35,7 +35,7 @@ export default gql` createComment(text: String!, parentId: ID!): Item! updateComment(id: ID!, text: String!): Item! dontLikeThis(id: ID!): Boolean! - act(id: ID!, sats: Int): ItemActResult! + act(id: ID!, sats: Int, invoiceId: ID): ItemActResult! pollVote(id: ID!): ID! } diff --git a/components/invoice.js b/components/invoice.js index 5e3987141..74fe7de4d 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -1,11 +1,12 @@ import Qr from './qr' -export function Invoice ({ invoice }) { +export function Invoice ({ invoice, onConfirmation, successVerb }) { let variant = 'default' let status = 'waiting for you' if (invoice.confirmedAt) { variant = 'confirmed' - status = `${invoice.satsReceived} sats deposited` + status = `${invoice.satsReceived} sats ${successVerb || 'deposited'}` + onConfirmation?.(invoice) } else if (invoice.cancelled) { variant = 'failed' status = 'cancelled' diff --git a/components/item-act.js b/components/item-act.js index 800b86918..77fe0a011 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -1,10 +1,11 @@ import Button from 'react-bootstrap/Button' import InputGroup from 'react-bootstrap/InputGroup' -import React, { useState, useRef, useEffect } from 'react' +import React, { useState, useRef, useEffect, useCallback } from 'react' import { Form, Input, SubmitButton } from './form' import { useMe } from './me' import UpBolt from '../svgs/bolt.svg' import { amountSchema } from '../lib/validate' +import { useAnonymous } from '../lib/anonymous' const defaultTips = [100, 1000, 10000, 100000] @@ -45,6 +46,27 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { inputRef.current?.focus() }, [onClose, itemId]) + const submitAct = useCallback( + async (amount, invoiceId) => { + if (!me) { + const storageKey = `TIP-item:${itemId}` + const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') + window.localStorage.setItem(storageKey, existingAmount + amount) + } + await act({ + variables: { + id: itemId, + sats: Number(amount), + invoiceId + } + }) + await strike() + addCustomTip(Number(amount)) + onClose() + }, [act, onClose, strike, itemId]) + + const anonAct = useAnonymous(submitAct) + return (
{ - await act({ - variables: { - id: itemId, - sats: Number(amount) - } - }) - await strike() - addCustomTip(Number(amount)) - onClose() + await anonAct(amount) }} > { if (!full) { setHasNewComments(newComments(item)) } }, [item]) + useEffect(() => { + if (item) setMeTotalSats(item.meSats + item.meAnonSats + pendingSats) + }, [item?.meSats, item?.meAnonSats, pendingSats]) + return (
{!item.position && <> - {abbrNum(item.sats + pendingSats)} sats + {abbrNum(item.sats + pendingSats)} sats \ } {item.boost > 0 && diff --git a/components/upvote.js b/components/upvote.js index c5281b1da..0fe7e73ae 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -11,7 +11,6 @@ import LongPressable from 'react-longpressable' import Overlay from 'react-bootstrap/Overlay' import Popover from 'react-bootstrap/Popover' import { useShowModal } from './modal' -import { useRouter } from 'next/router' import { LightningConsumer } from './lightning' const getColor = (meSats) => { @@ -66,7 +65,6 @@ const TipPopover = ({ target, show, handleClose }) => ( export default function UpVote ({ item, className, pendingSats, setPendingSats }) { const showModal = useShowModal() - const router = useRouter() const [voteShow, _setVoteShow] = useState(false) const [tipShow, _setTipShow] = useState(false) const ref = useRef() @@ -110,8 +108,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } const [act] = useMutation( gql` - mutation act($id: ID!, $sats: Int!) { - act(id: $id, sats: $sats) { + mutation act($id: ID!, $sats: Int!, $invoiceId: ID) { + act(id: $id, sats: $sats, invoiceId: $invoiceId) { sats } }`, { @@ -122,17 +120,19 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } sats (existingSats = 0) { return existingSats + sats }, - meSats (existingSats = 0) { - if (sats <= me.sats) { - if (existingSats === 0) { - setVoteShow(true) - } else { - setTipShow(true) - } - } + meSats: me + ? (existingSats = 0) => { + if (sats <= me.sats) { + if (existingSats === 0) { + setVoteShow(true) + } else { + setTipShow(true) + } + } - return existingSats + sats - } + return existingSats + sats + } + : undefined } }) @@ -197,8 +197,9 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt }, [me?.id, item?.fwdUserId, item?.mine, item?.deletedAt]) - const [meSats, sats, overlayText, color] = useMemo(() => { + const [meSats, meTotalSats, sats, overlayText, color] = useMemo(() => { const meSats = (item?.meSats || 0) + pendingSats + const meTotalSats = meSats + (item?.meAnonSats || 0) // what should our next tip be? let sats = me?.tipDefault || 1 @@ -211,8 +212,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } sats = raiseTip - meSats } - return [meSats, sats, `${sats} sat${sats > 1 ? 's' : ''}`, getColor(meSats)] - }, [item?.meSats, pendingSats, me?.tipDefault, me?.turboDefault]) + return [meSats, meTotalSats, sats, `${sats} sat${sats > 1 ? 's' : ''}`, getColor(meTotalSats)] + }, [item?.meSats, item?.meAnonSats, pendingSats, me?.tipDefault, me?.turboDefault]) return ( @@ -251,10 +252,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } setPendingSats(pendingSats + sats) } - : async () => await router.push({ - pathname: '/signup', - query: { callbackUrl: window.location.origin + router.asPath } - }) + : () => showModal(onClose => ) } > @@ -268,9 +266,9 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } `${styles.upvote} ${className || ''} ${disabled ? styles.noSelfTips : ''} - ${meSats ? styles.voted : ''}` + ${meTotalSats ? styles.voted : ''}` } - style={meSats + style={meTotalSats ? { fill: color, filter: `drop-shadow(0 0 6px ${color}90)` diff --git a/fragments/comments.js b/fragments/comments.js index b8db60bb3..c57cb5fb3 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -14,6 +14,7 @@ export const COMMENT_FIELDS = gql` id } sats + meAnonSats @client upvotes wvotes boost diff --git a/fragments/items.js b/fragments/items.js index 0d9c12546..c93e27436 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -19,6 +19,7 @@ export const ITEM_FIELDS = gql` otsHash position sats + meAnonSats @client boost bounty bountyPaidTo diff --git a/lib/anonymous.js b/lib/anonymous.js new file mode 100644 index 000000000..46b0f5baf --- /dev/null +++ b/lib/anonymous.js @@ -0,0 +1,83 @@ +import { useMutation, useQuery } from '@apollo/client' +import { GraphQLError } from 'graphql' +import { gql } from 'graphql-tag' +import { useCallback, useEffect, useState } from 'react' +import { useShowModal } from '../components/modal' +import { Invoice as QrInvoice } from '../components/invoice' +import { QrSkeleton } from '../components/qr' +import { useMe } from '../components/me' +import { msatsToSats } from './format' +import { INVOICE } from '../fragments/wallet' + +const Invoice = ({ id, ...props }) => { + const { data, loading, error } = useQuery(INVOICE, { + pollInterval: 1000, + variables: { id } + }) + if (error) { + console.log(error) + return
error
+ } + if (!data || loading) { + return + } + return +} + +export const useAnonymous = (fn) => { + const me = useMe() + const [createInvoice, { data }] = useMutation(gql` + mutation createInvoice($amount: Int!) { + createInvoice(amount: $amount) { + id + } + }`) + const showModal = useShowModal() + const [fnArgs, setFnArgs] = useState() + + const invoice = data?.createInvoice + useEffect(() => { + if (invoice) { + showModal(onClose => + { + setTimeout(async () => { + await fn(satsReceived, ...fnArgs, id) + onClose() + }, 2000) + } + } successVerb='received' + /> + ) + } + }, [invoice?.id]) + + const anonFn = useCallback((amount, ...args) => { + if (me) return fn(amount, ...args) + setFnArgs(args) + return createInvoice({ variables: { amount } }) + }) + + return anonFn +} + +export const checkInvoice = async (models, invoiceId, fee) => { + const invoice = await models.invoice.findUnique({ + where: { id: Number(invoiceId) }, + include: { + user: true + } + }) + if (!invoice) { + throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) + } + if (!invoice.msatsReceived) { + throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } }) + } + if (msatsToSats(invoice.msatsReceived) < fee) { + throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } }) + } + return invoice +} diff --git a/lib/apollo.js b/lib/apollo.js index 25ba82d50..382ce4278 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -141,6 +141,17 @@ function getClient (uri) { } } } + }, + Item: { + fields: { + meAnonSats: { + read (meAnonSats, { readField }) { + if (typeof window === 'undefined') return null + const itemId = readField('id') + return meAnonSats ?? Number(localStorage.getItem(`TIP-item:${itemId}`) || '0') + } + } + } } } }), diff --git a/lib/constants.js b/lib/constants.js index 3af84eca8..f0bcc8df5 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -44,3 +44,5 @@ export const ITEM_TYPES = context => { } export const OLD_ITEM_DAYS = 3 + +export const ANON_USER_ID = 27 diff --git a/pages/invoices/[id].js b/pages/invoices/[id].js index 7ff3cec9e..54405bc04 100644 --- a/pages/invoices/[id].js +++ b/pages/invoices/[id].js @@ -5,7 +5,7 @@ import { CenterLayout } from '../../components/layout' import { useRouter } from 'next/router' import { INVOICE } from '../../fragments/wallet' -export default function FullInvoice () { +export default function FullInvoice ({ id }) { const router = useRouter() const { data, error } = useQuery(INVOICE, { pollInterval: 1000, diff --git a/prisma/migrations/20230719195700_anon_update/migration.sql b/prisma/migrations/20230719195700_anon_update/migration.sql new file mode 100644 index 000000000..49cbe8005 --- /dev/null +++ b/prisma/migrations/20230719195700_anon_update/migration.sql @@ -0,0 +1 @@ +UPDATE users SET "hideInvoiceDesc" = 't' WHERE id = 27; From 74893b09dda8f94d3e0239bd968fe701a891c8d1 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 19 Jul 2023 19:06:52 +0200 Subject: [PATCH 02/54] Add anon comments and posts (link, discussion, poll) --- api/resolvers/item.js | 52 +++++++++++------ api/typeDefs/item.js | 8 +-- components/discussion-form.js | 40 ++++++++----- components/fee-button.js | 6 +- components/header.js | 3 - components/link-form.js | 39 ++++++++----- components/poll-form.js | 56 +++++++++++-------- components/post.js | 20 ++++++- components/reply.js | 27 ++++++--- lib/anonymous.js | 2 +- lib/constants.js | 2 + .../migration.sql | 36 ++++++++++++ 12 files changed, 201 insertions(+), 90 deletions(-) create mode 100644 prisma/migrations/20230719195700_disable_fee_escalation_for_anons/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index cb20b05ae..94b994632 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -7,7 +7,8 @@ import domino from 'domino' import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, - DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY + DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, + ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE } from '../../lib/constants' import { msatsToSats } from '../../lib/format' import { parse } from 'tldts' @@ -571,7 +572,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models }) + return await createItem(parent, data, { me, models, invoiceId: args.invoiceId }) } }, upsertDiscussion: async (parent, args, { me, models }) => { @@ -582,7 +583,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models }) + return await createItem(parent, data, { me, models, invoiceId: args.invoiceId }) } }, upsertBounty: async (parent, args, { me, models }) => { @@ -597,8 +598,16 @@ export default { } }, upsertPoll: async (parent, { id, ...data }, { me, models }) => { - const { forward, sub, boost, title, text, options } = data - if (!me) { + const { sub, forward, boost, title, text, options, invoiceId } = data + let author = me + const trx = [] + if (!me && invoiceId) { + const invoice = await checkInvoice(models, invoiceId, ANON_POST_FEE) + author = invoice.user + trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + } + + if (!author) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } @@ -622,7 +631,7 @@ export default { if (id) { const old = await models.item.findUnique({ where: { id: Number(id) } }) - if (Number(old.userId) !== Number(me?.id)) { + if (Number(old.userId) !== Number(author.id)) { throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) } const [item] = await serialize(models, @@ -633,9 +642,10 @@ export default { item.comments = [] return item } else { - const [item] = await serialize(models, + 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"`, - sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id))) + sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, Number(fwdUser?.id)), ...trx) + const item = trx.length > 0 ? query[0] : query await createMentions(item, models) item.comments = [] @@ -679,13 +689,13 @@ export default { }, createComment: async (parent, data, { me, models }) => { await ssValidate(commentSchema, data) - const item = await createItem(parent, data, { me, models }) + const item = await createItem(parent, data, { me, models, invoiceId: data.invoiceId }) // fetch user to get up-to-date name - const user = await models.user.findUnique({ where: { id: me.id } }) + const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) const parents = await models.$queryRawUnsafe( 'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2', - Number(item.parentId), Number(me.id)) + Number(item.parentId), Number(user.id)) Promise.allSettled( parents.map(({ userId }) => sendUserNotification(userId, { title: `@${user.name} replied to you`, @@ -1065,8 +1075,16 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo return item } -const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models }) => { - if (!me) { +const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceId }) => { + let author = me + const trx = [] + if (!me && invoiceId) { + const invoice = await checkInvoice(models, invoiceId, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) + author = invoice.user + trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + } + + if (!author) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } @@ -1089,7 +1107,7 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount url = await proxyImages(url) text = await proxyImages(text) - const [item] = await serialize( + 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"`, @@ -1100,8 +1118,10 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount Number(boost || 0), bounty ? Number(bounty) : null, Number(parentId), - Number(me.id), - Number(fwdUser?.id))) + Number(author.id), + Number(fwdUser?.id)), + ...trx) + const item = trx.length > 0 ? query[0] : query await createMentions(item, models) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 7962d3c89..86e5d99e5 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -26,13 +26,13 @@ export default gql` bookmarkItem(id: ID): Item subscribeItem(id: ID): Item deleteItem(id: ID): Item - upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String): Item! - upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String): Item! + upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceId: ID): Item! + upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceId: ID): Item! upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item! upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! - upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item! - createComment(text: String!, parentId: ID!): Item! + upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceId: ID): Item! + createComment(text: String!, parentId: ID!, invoiceId: ID): Item! updateComment(id: ID!, text: String!): Item! dontLikeThis(id: ID!): Boolean! act(id: ID!, sats: Int, invoiceId: ID): ItemActResult! diff --git a/components/discussion-form.js b/components/discussion-form.js index 3243fd033..3e43418ed 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -12,6 +12,9 @@ import Button from 'react-bootstrap/Button' import { discussionSchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' +import { useCallback } from 'react' +import { useAnonymous } from '../lib/anonymous' +import { ANON_POST_FEE } from '../lib/constants' export function DiscussionForm ({ item, sub, editThreshold, titleLabel = 'title', @@ -27,13 +30,32 @@ export function DiscussionForm ({ // const me = useMe() const [upsertDiscussion] = useMutation( gql` - mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String) { - upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward) { + mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceId: ID) { + upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceId: $invoiceId) { id } }` ) + const submitUpsertDiscussion = useCallback( + async (_, boost, values, invoiceId) => { + const { error } = await upsertDiscussion({ + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceId } + }) + if (error) { + throw new Error({ message: error.toString() }) + } + + if (item) { + await router.push(`/items/${item.id}`) + } else { + const prefix = sub?.name ? `/~${sub.name}` : '' + await router.push(prefix + '/recent') + } + }, [upsertDiscussion, router]) + + const anonUpsertDiscussion = useAnonymous(submitUpsertDiscussion) + const [getRelated, { data: relatedData }] = useLazyQuery(gql` ${ITEM_FIELDS} query related($title: String!) { @@ -58,19 +80,7 @@ export function DiscussionForm ({ }} schema={schema} onSubmit={handleSubmit || (async ({ boost, ...values }) => { - const { error } = await upsertDiscussion({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values } - }) - if (error) { - throw new Error({ message: error.toString() }) - } - - if (item) { - await router.push(`/items/${item.id}`) - } else { - const prefix = sub?.name ? `/~${sub.name}` : '' - await router.push(prefix + '/recent') - } + await anonUpsertDiscussion(ANON_POST_FEE, boost, values) })} storageKeyPrefix={item ? undefined : 'discussion'} > diff --git a/components/fee-button.js b/components/fee-button.js index 6b47f25b5..1eae25061 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -4,6 +4,8 @@ import Info from './info' import styles from './fee-button.module.css' import { gql, useQuery } from '@apollo/client' import { useFormikContext } from 'formik' +import { useMe } from './me' +import { ANON_COMMENT_FEE, ANON_POST_FEE } from '../lib/constants' function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { return ( @@ -40,11 +42,13 @@ function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { } export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow, disabled }) { + const me = useMe() + baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) const query = parentId ? gql`{ itemRepetition(parentId: "${parentId}") }` : gql`{ itemRepetition }` const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) - const repetition = data?.itemRepetition || 0 + const repetition = me ? data?.itemRepetition || 0 : 0 const formik = useFormikContext() const boost = Number(formik?.values?.boost) || 0 const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost) diff --git a/components/header.js b/components/header.js index 07fd75328..6ed6a1153 100644 --- a/components/header.js +++ b/components/header.js @@ -212,9 +212,6 @@ function NavItems ({ className, sub, prefix }) { } function PostItem ({ className, prefix }) { - const me = useMe() - if (!me) return null - return ( post diff --git a/components/link-form.js b/components/link-form.js index 3aef521d1..33c7bc479 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Form, Input, SubmitButton } from '../components/form' import { useRouter } from 'next/router' import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client' @@ -14,6 +14,8 @@ import { linkSchema } from '../lib/validate' import Moon from '../svgs/moon-fill.svg' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' +import { useAnonymous } from '../lib/anonymous' +import { ANON_POST_FEE } from '../lib/constants' export function LinkForm ({ item, sub, editThreshold, children }) { const router = useRouter() @@ -66,13 +68,31 @@ export function LinkForm ({ item, sub, editThreshold, children }) { const [upsertLink] = useMutation( gql` - mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String) { - upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward) { + mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceId: ID) { + upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceId: $invoiceId) { id } }` ) + const submitUpsertLink = useCallback( + async (_, boost, title, values, invoiceId) => { + const { error } = await upsertLink({ + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceId, ...values } + }) + if (error) { + throw new Error({ message: error.toString() }) + } + if (item) { + await router.push(`/items/${item.id}`) + } else { + const prefix = sub?.name ? `/~${sub.name}` : '' + await router.push(prefix + '/recent') + } + }, [upsertLink, router]) + + const anonUpsertLink = useAnonymous(submitUpsertLink) + useEffect(() => { if (data?.pageTitleAndUnshorted?.title) { setTitleOverride(data.pageTitleAndUnshorted.title) @@ -100,18 +120,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) { }} schema={schema} onSubmit={async ({ boost, title, ...values }) => { - const { error } = await upsertLink({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), ...values } - }) - if (error) { - throw new Error({ message: error.toString() }) - } - if (item) { - await router.push(`/items/${item.id}`) - } else { - const prefix = sub?.name ? `/~${sub.name}` : '' - await router.push(prefix + '/recent') - } + await anonUpsertLink(ANON_POST_FEE, boost, title, values) }} storageKeyPrefix={item ? undefined : 'link'} > diff --git a/components/poll-form.js b/components/poll-form.js index 9493e1093..3cc6c7067 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -3,13 +3,15 @@ import { useRouter } from 'next/router' import { gql, useApolloClient, useMutation } from '@apollo/client' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial } from './adv-post-form' -import { MAX_POLL_NUM_CHOICES } from '../lib/constants' +import { ANON_POST_FEE, MAX_POLL_NUM_CHOICES } from '../lib/constants' import FeeButton, { EditFeeButton } from './fee-button' import Delete from './delete' import Button from 'react-bootstrap/Button' import { pollSchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' +import { useCallback } from 'react' +import { useAnonymous } from '../lib/anonymous' export function PollForm ({ item, sub, editThreshold, children }) { const router = useRouter() @@ -19,14 +21,41 @@ export function PollForm ({ item, sub, editThreshold, children }) { const [upsertPoll] = useMutation( gql` mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, - $options: [String!]!, $boost: Int, $forward: String) { + $options: [String!]!, $boost: Int, $forward: String, $invoiceId: ID) { upsertPoll(sub: $sub, id: $id, title: $title, text: $text, - options: $options, boost: $boost, forward: $forward) { + options: $options, boost: $boost, forward: $forward, invoiceId: $invoiceId) { id } }` ) + const submitUpsertPoll = useCallback( + async (_, boost, title, options, values, invoiceId) => { + const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) + const { error } = await upsertPoll({ + variables: { + id: item?.id, + sub: item?.subName || sub?.name, + boost: boost ? Number(boost) : undefined, + title: title.trim(), + options: optionsFiltered, + ...values, + invoiceId + } + }) + if (error) { + throw new Error({ message: error.toString() }) + } + if (item) { + await router.push(`/items/${item.id}`) + } else { + const prefix = sub?.name ? `/~${sub.name}` : '' + await router.push(prefix + '/recent') + } + }, [upsertPoll, router]) + + const anonUpsertPoll = useAnonymous(submitUpsertPoll) + const initialOptions = item?.poll?.options.map(i => i.option) return ( @@ -40,26 +69,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { }} schema={schema} onSubmit={async ({ boost, title, options, ...values }) => { - const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) - const { error } = await upsertPoll({ - variables: { - id: item?.id, - sub: item?.subName || sub?.name, - boost: boost ? Number(boost) : undefined, - title: title.trim(), - options: optionsFiltered, - ...values - } - }) - if (error) { - throw new Error({ message: error.toString() }) - } - if (item) { - await router.push(`/items/${item.id}`) - } else { - const prefix = sub?.name ? `/~${sub.name}` : '' - await router.push(prefix + '/recent') - } + await anonUpsertPoll(ANON_POST_FEE, boost, title, options, values) }} storageKeyPrefix={item ? undefined : 'poll'} > diff --git a/components/post.js b/components/post.js index 6723a771b..73877bbd1 100644 --- a/components/post.js +++ b/components/post.js @@ -1,6 +1,7 @@ import JobForm from './job-form' import Link from 'next/link' import Button from 'react-bootstrap/Button' +import Alert from 'react-bootstrap/Alert' import AccordianItem from './accordian-item' import { useMe } from './me' import { useRouter } from 'next/router' @@ -10,6 +11,7 @@ import { PollForm } from './poll-form' import { BountyForm } from './bounty-form' import SubSelect from './sub-select-form' import Info from './info' +import { useCallback, useState } from 'react' function FreebieDialog () { return ( @@ -28,12 +30,24 @@ function FreebieDialog () { export function PostForm ({ type, sub, children }) { const me = useMe() + const [errorMessage, setErrorMessage] = useState() const prefix = sub?.name ? `/~${sub.name}` : '' + const checkSession = useCallback((e) => { + if (!me) { + e.preventDefault() + setErrorMessage('you must be logged in') + } + }, [me, setErrorMessage]) + if (!type) { return ( -
+
+ {errorMessage && + setErrorMessage(undefined)} dismissible> + {errorMessage} + } {me?.sats < 1 && } @@ -54,11 +68,11 @@ export function PostForm ({ type, sub, children }) { or - +
- +
diff --git a/components/reply.js b/components/reply.js index 1eda2eb71..587456537 100644 --- a/components/reply.js +++ b/components/reply.js @@ -3,12 +3,14 @@ import { gql, useMutation } from '@apollo/client' import styles from './reply.module.css' import { COMMENTS } from '../fragments/comments' import { useMe } from './me' -import { useEffect, useState, useRef } from 'react' +import { useEffect, useState, useRef, useCallback } from 'react' import Link from 'next/link' import FeeButton from './fee-button' import { commentsViewedAfterComment } from '../lib/new-comments' import { commentSchema } from '../lib/validate' import Info from './info' +import { useAnonymous } from '../lib/anonymous' +import { ANON_COMMENT_FEE } from '../lib/constants' export function ReplyOnAnotherPage ({ parentId }) { return ( @@ -45,8 +47,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold const [createComment] = useMutation( gql` ${COMMENTS} - mutation createComment($text: String!, $parentId: ID!) { - createComment(text: $text, parentId: $parentId) { + mutation createComment($text: String!, $parentId: ID!, $invoiceId: ID) { + createComment(text: $text, parentId: $parentId, invoiceId: $invoiceId) { ...CommentFields comments { ...CommentsRecursive @@ -90,6 +92,18 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold } ) + const submitComment = useCallback( + async (_, values, parentId, resetForm, invoiceId) => { + const { error } = await createComment({ variables: { ...values, parentId, invoiceId } }) + if (error) { + throw new Error({ message: error.toString() }) + } + resetForm({ text: '' }) + setReply(replyOpen || false) + }, [createComment, setReply]) + + const anonCreateComment = useAnonymous(submitComment) + const replyInput = useRef(null) useEffect(() => { if (replyInput.current && reply && !replyOpen) replyInput.current.focus() @@ -117,12 +131,7 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold }} schema={commentSchema} onSubmit={async (values, { resetForm }) => { - const { error } = await createComment({ variables: { ...values, parentId } }) - if (error) { - throw new Error({ message: error.toString() }) - } - resetForm({ text: '' }) - setReply(replyOpen || false) + await anonCreateComment(ANON_COMMENT_FEE, values, parentId, resetForm) }} storageKeyPrefix={'reply-' + parentId} > diff --git a/lib/anonymous.js b/lib/anonymous.js index 46b0f5baf..f47a5a66d 100644 --- a/lib/anonymous.js +++ b/lib/anonymous.js @@ -58,7 +58,7 @@ export const useAnonymous = (fn) => { if (me) return fn(amount, ...args) setFnArgs(args) return createInvoice({ variables: { amount } }) - }) + }, [fn, setFnArgs, createInvoice]) return anonFn } diff --git a/lib/constants.js b/lib/constants.js index f0bcc8df5..5c42a6931 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -46,3 +46,5 @@ export const ITEM_TYPES = context => { export const OLD_ITEM_DAYS = 3 export const ANON_USER_ID = 27 +export const ANON_POST_FEE = 1000 +export const ANON_COMMENT_FEE = 100 diff --git a/prisma/migrations/20230719195700_disable_fee_escalation_for_anons/migration.sql b/prisma/migrations/20230719195700_disable_fee_escalation_for_anons/migration.sql new file mode 100644 index 000000000..28992e63e --- /dev/null +++ b/prisma/migrations/20230719195700_disable_fee_escalation_for_anons/migration.sql @@ -0,0 +1,36 @@ +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 + IF user_id = 27 THEN + -- disable fee escalation for anon user + 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 From fd8510d59f32b36e8c64f8d84ea9ec55b99e9af0 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 20 Jul 2023 16:55:28 +0200 Subject: [PATCH 03/54] Use payment hash instead of invoice id as proof of payment Our invoice IDs can be enumerated. So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself. Random payment hashes prevent this. Also, since we delete invoices after use, using database IDs as proof of payments are not suitable. If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs. --- api/resolvers/item.js | 34 +++++++++++++++++----------------- api/typeDefs/item.js | 10 +++++----- api/typeDefs/wallet.js | 1 + components/discussion-form.js | 8 ++++---- components/item-act.js | 4 ++-- components/link-form.js | 8 ++++---- components/poll-form.js | 8 ++++---- components/reply.js | 8 ++++---- components/upvote.js | 4 ++-- fragments/wallet.js | 1 + lib/anonymous.js | 9 +++++---- 11 files changed, 49 insertions(+), 46 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 94b994632..ff356d6c2 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -572,7 +572,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceId: args.invoiceId }) + return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash }) } }, upsertDiscussion: async (parent, args, { me, models }) => { @@ -583,7 +583,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceId: args.invoiceId }) + return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash }) } }, upsertBounty: async (parent, args, { me, models }) => { @@ -598,13 +598,13 @@ export default { } }, upsertPoll: async (parent, { id, ...data }, { me, models }) => { - const { sub, forward, boost, title, text, options, invoiceId } = data + const { sub, forward, boost, title, text, options, invoiceHash } = data let author = me const trx = [] - if (!me && invoiceId) { - const invoice = await checkInvoice(models, invoiceId, ANON_POST_FEE) + if (!me && invoiceHash) { + const invoice = await checkInvoice(models, invoiceHash, ANON_POST_FEE) author = invoice.user - trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } if (!author) { @@ -689,7 +689,7 @@ export default { }, createComment: async (parent, data, { me, models }) => { await ssValidate(commentSchema, data) - const item = await createItem(parent, data, { me, models, invoiceId: data.invoiceId }) + const item = await createItem(parent, data, { me, models, invoiceHash: data.invoiceHash }) // fetch user to get up-to-date name const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) @@ -722,17 +722,17 @@ export default { return id }, - act: async (parent, { id, sats, invoiceId }, { me, models }) => { + act: async (parent, { id, sats, invoiceHash }, { me, models }) => { // need to make sure we are logged in - if (!me && !invoiceId) { + if (!me && !invoiceHash) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } await ssValidate(amountSchema, { amount: sats }) let user = me - if (!me && invoiceId) { - const invoice = await checkInvoice(models, invoiceId, sats) + if (!me && invoiceHash) { + const invoice = await checkInvoice(models, invoiceHash, sats) user = invoice.user } @@ -748,8 +748,8 @@ export default { const calls = [ models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` ] - if (!me && invoiceId) { - calls.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + if (!me && invoiceHash) { + calls.push(models.invoice.delete({ where: { hash: invoiceHash } })) } const [{ item_act: vote }] = await serialize(models, ...calls) @@ -1075,13 +1075,13 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo return item } -const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceId }) => { +const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash }) => { let author = me const trx = [] - if (!me && invoiceId) { - const invoice = await checkInvoice(models, invoiceId, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) + if (!me && invoiceHash) { + const invoice = await checkInvoice(models, invoiceHash, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) author = invoice.user - trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } if (!author) { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 86e5d99e5..0ca7f821e 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -26,16 +26,16 @@ export default gql` bookmarkItem(id: ID): Item subscribeItem(id: ID): Item deleteItem(id: ID): Item - upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceId: ID): Item! - upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceId: ID): Item! + upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceHash: String): Item! + upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceHash: String): Item! upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item! upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! - upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceId: ID): Item! - createComment(text: String!, parentId: ID!, invoiceId: ID): Item! + upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceHash: String): Item! + createComment(text: String!, parentId: ID!, invoiceHash: String): Item! updateComment(id: ID!, text: String!): Item! dontLikeThis(id: ID!): Boolean! - act(id: ID!, sats: Int, invoiceId: ID): ItemActResult! + act(id: ID!, sats: Int, invoiceHash: String): ItemActResult! pollVote(id: ID!): ID! } diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index dc894e9bf..698e36953 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -17,6 +17,7 @@ export default gql` type Invoice { id: ID! createdAt: Date! + hash: String! bolt11: String! expiresAt: Date! cancelled: Boolean! diff --git a/components/discussion-form.js b/components/discussion-form.js index 3e43418ed..600b50bd5 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -30,17 +30,17 @@ export function DiscussionForm ({ // const me = useMe() const [upsertDiscussion] = useMutation( gql` - mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceId: ID) { - upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceId: $invoiceId) { + mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceHash: String) { + upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { id } }` ) const submitUpsertDiscussion = useCallback( - async (_, boost, values, invoiceId) => { + async (_, boost, values, invoiceHash) => { const { error } = await upsertDiscussion({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceId } + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceHash } }) if (error) { throw new Error({ message: error.toString() }) diff --git a/components/item-act.js b/components/item-act.js index 77fe0a011..e5323fdf9 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -47,7 +47,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { }, [onClose, itemId]) const submitAct = useCallback( - async (amount, invoiceId) => { + async (amount, invoiceHash) => { if (!me) { const storageKey = `TIP-item:${itemId}` const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') @@ -57,7 +57,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { variables: { id: itemId, sats: Number(amount), - invoiceId + invoiceHash } }) await strike() diff --git a/components/link-form.js b/components/link-form.js index 33c7bc479..03465f165 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -68,17 +68,17 @@ export function LinkForm ({ item, sub, editThreshold, children }) { const [upsertLink] = useMutation( gql` - mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceId: ID) { - upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceId: $invoiceId) { + mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceHash: String) { + upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { id } }` ) const submitUpsertLink = useCallback( - async (_, boost, title, values, invoiceId) => { + async (_, boost, title, values, invoiceHash) => { const { error } = await upsertLink({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceId, ...values } + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceHash, ...values } }) if (error) { throw new Error({ message: error.toString() }) diff --git a/components/poll-form.js b/components/poll-form.js index 3cc6c7067..d719def0e 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -21,16 +21,16 @@ export function PollForm ({ item, sub, editThreshold, children }) { const [upsertPoll] = useMutation( gql` mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, - $options: [String!]!, $boost: Int, $forward: String, $invoiceId: ID) { + $options: [String!]!, $boost: Int, $forward: String, $invoiceHash: String) { upsertPoll(sub: $sub, id: $id, title: $title, text: $text, - options: $options, boost: $boost, forward: $forward, invoiceId: $invoiceId) { + options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { id } }` ) const submitUpsertPoll = useCallback( - async (_, boost, title, options, values, invoiceId) => { + async (_, boost, title, options, values, invoiceHash) => { const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) const { error } = await upsertPoll({ variables: { @@ -40,7 +40,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { title: title.trim(), options: optionsFiltered, ...values, - invoiceId + invoiceHash } }) if (error) { diff --git a/components/reply.js b/components/reply.js index 587456537..6011fef06 100644 --- a/components/reply.js +++ b/components/reply.js @@ -47,8 +47,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold const [createComment] = useMutation( gql` ${COMMENTS} - mutation createComment($text: String!, $parentId: ID!, $invoiceId: ID) { - createComment(text: $text, parentId: $parentId, invoiceId: $invoiceId) { + mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String) { + createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash) { ...CommentFields comments { ...CommentsRecursive @@ -93,8 +93,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold ) const submitComment = useCallback( - async (_, values, parentId, resetForm, invoiceId) => { - const { error } = await createComment({ variables: { ...values, parentId, invoiceId } }) + async (_, values, parentId, resetForm, invoiceHash) => { + const { error } = await createComment({ variables: { ...values, parentId, invoiceHash } }) if (error) { throw new Error({ message: error.toString() }) } diff --git a/components/upvote.js b/components/upvote.js index 0fe7e73ae..c19572fc3 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -108,8 +108,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } const [act] = useMutation( gql` - mutation act($id: ID!, $sats: Int!, $invoiceId: ID) { - act(id: $id, sats: $sats, invoiceId: $invoiceId) { + mutation act($id: ID!, $sats: Int!, $invoiceHash: String) { + act(id: $id, sats: $sats, invoiceHash: $invoiceHash) { sats } }`, { diff --git a/fragments/wallet.js b/fragments/wallet.js index 62f55d1c7..db0cae7fb 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -5,6 +5,7 @@ export const INVOICE = gql` query Invoice($id: ID!) { invoice(id: $id) { id + hash bolt11 satsReceived cancelled diff --git a/lib/anonymous.js b/lib/anonymous.js index f47a5a66d..b77058699 100644 --- a/lib/anonymous.js +++ b/lib/anonymous.js @@ -30,6 +30,7 @@ export const useAnonymous = (fn) => { mutation createInvoice($amount: Int!) { createInvoice(amount: $amount) { id + hash } }`) const showModal = useShowModal() @@ -42,9 +43,9 @@ export const useAnonymous = (fn) => { { + async ({ satsReceived }) => { setTimeout(async () => { - await fn(satsReceived, ...fnArgs, id) + await fn(satsReceived, ...fnArgs, invoice.hash) onClose() }, 2000) } @@ -63,9 +64,9 @@ export const useAnonymous = (fn) => { return anonFn } -export const checkInvoice = async (models, invoiceId, fee) => { +export const checkInvoice = async (models, invoiceHash, fee) => { const invoice = await models.invoice.findUnique({ - where: { id: Number(invoiceId) }, + where: { hash: invoiceHash }, include: { user: true } From 853a389b6543817e7f857bcaed404b9e3c195baf Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 21 Jul 2023 00:34:39 +0200 Subject: [PATCH 04/54] Allow pay per invoice for stackers The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice" --- components/discussion-form.js | 5 ++--- components/fee-button.js | 5 +++++ components/fund-error.js | 8 ++++++-- components/item-act.js | 21 ++++++++++++++++++++- components/link-form.js | 5 ++--- components/poll-form.js | 6 +++--- components/upvote.js | 13 +++++++++++-- lib/anonymous.js | 28 +++++++++++++++++++++++++--- 8 files changed, 74 insertions(+), 17 deletions(-) diff --git a/components/discussion-form.js b/components/discussion-form.js index 600b50bd5..14271f73c 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -14,7 +14,6 @@ import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' import { useCallback } from 'react' import { useAnonymous } from '../lib/anonymous' -import { ANON_POST_FEE } from '../lib/constants' export function DiscussionForm ({ item, sub, editThreshold, titleLabel = 'title', @@ -79,8 +78,8 @@ export function DiscussionForm ({ ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} - onSubmit={handleSubmit || (async ({ boost, ...values }) => { - await anonUpsertDiscussion(ANON_POST_FEE, boost, values) + onSubmit={handleSubmit || (async ({ boost, cost, ...values }) => { + await anonUpsertDiscussion(cost, boost, values) })} storageKeyPrefix={item ? undefined : 'discussion'} > diff --git a/components/fee-button.js b/components/fee-button.js index 1eae25061..f6daeac1f 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -6,6 +6,7 @@ import { gql, useQuery } from '@apollo/client' import { useFormikContext } from 'formik' import { useMe } from './me' import { ANON_COMMENT_FEE, ANON_POST_FEE } from '../lib/constants' +import { useEffect } from 'react' function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { return ( @@ -53,6 +54,10 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, const boost = Number(formik?.values?.boost) || 0 const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost) + useEffect(() => { + formik.setFieldValue('cost', cost) + }, [cost]) + const show = alwaysShow || !formik?.isSubmitting return (
diff --git a/components/fund-error.js b/components/fund-error.js index e25cddfed..01297e8d9 100644 --- a/components/fund-error.js +++ b/components/fund-error.js @@ -1,14 +1,18 @@ import Link from 'next/link' import Button from 'react-bootstrap/Button' +import { useAnonymous } from '../lib/anonymous' -export default function FundError ({ onClose }) { +export default function FundError ({ onClose, amount, onPayment }) { + const anonPayment = useAnonymous(onPayment, { forceInvoice: true }) return ( <>

you need more sats

- + + or +
) diff --git a/components/item-act.js b/components/item-act.js index e5323fdf9..84884bd3e 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -6,6 +6,8 @@ import { useMe } from './me' import UpBolt from '../svgs/bolt.svg' import { amountSchema } from '../lib/validate' import { useAnonymous } from '../lib/anonymous' +import { useShowModal } from './modal' +import FundError from './fund-error' const defaultTips = [100, 1000, 10000, 100000] @@ -41,6 +43,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { const inputRef = useRef(null) const me = useMe() const [oValue, setOValue] = useState() + const showModal = useShowModal() useEffect(() => { inputRef.current?.focus() @@ -75,7 +78,23 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { }} schema={amountSchema} onSubmit={async ({ amount }) => { - await anonAct(amount) + try { + await anonAct(amount) + } catch (error) { + if (error.toString().includes('insufficient funds')) { + showModal(onClose => { + return ( + + ) + }) + return + } + throw new Error({ message: error.toString() }) + } }} > { - await anonUpsertLink(ANON_POST_FEE, boost, title, values) + onSubmit={async ({ boost, title, cost, ...values }) => { + await anonUpsertLink(cost, boost, title, values) }} storageKeyPrefix={item ? undefined : 'link'} > diff --git a/components/poll-form.js b/components/poll-form.js index d719def0e..5cf26156a 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -3,7 +3,7 @@ import { useRouter } from 'next/router' import { gql, useApolloClient, useMutation } from '@apollo/client' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial } from './adv-post-form' -import { ANON_POST_FEE, MAX_POLL_NUM_CHOICES } from '../lib/constants' +import { MAX_POLL_NUM_CHOICES } from '../lib/constants' import FeeButton, { EditFeeButton } from './fee-button' import Delete from './delete' import Button from 'react-bootstrap/Button' @@ -68,8 +68,8 @@ export function PollForm ({ item, sub, editThreshold, children }) { ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} - onSubmit={async ({ boost, title, options, ...values }) => { - await anonUpsertPoll(ANON_POST_FEE, boost, title, options, values) + onSubmit={async ({ boost, title, options, cost, ...values }) => { + await anonUpsertPoll(cost, boost, title, options, values) }} storageKeyPrefix={item ? undefined : 'poll'} > diff --git a/components/upvote.js b/components/upvote.js index c19572fc3..a04e69a4c 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -163,10 +163,11 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } if (pendingSats > 0) { timerRef.current = setTimeout(async (sats) => { + const variables = { id: item.id, sats: pendingSats } try { timerRef.current && setPendingSats(0) await act({ - variables: { id: item.id, sats }, + variables, optimisticResponse: { act: { sats @@ -178,7 +179,15 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } if (error.toString().includes('insufficient funds')) { showModal(onClose => { - return + return ( + { + await act({ variables: { ...variables, invoiceHash } }) + }} + /> + ) }) return } diff --git a/lib/anonymous.js b/lib/anonymous.js index b77058699..16d955433 100644 --- a/lib/anonymous.js +++ b/lib/anonymous.js @@ -7,6 +7,7 @@ import { Invoice as QrInvoice } from '../components/invoice' import { QrSkeleton } from '../components/qr' import { useMe } from '../components/me' import { msatsToSats } from './format' +import FundError from '../components/fund-error' import { INVOICE } from '../fragments/wallet' const Invoice = ({ id, ...props }) => { @@ -24,7 +25,10 @@ const Invoice = ({ id, ...props }) => { return } -export const useAnonymous = (fn) => { +const defaultOptions = { + forceInvoice: false +} +export const useAnonymous = (fn, options = defaultOptions) => { const me = useMe() const [createInvoice, { data }] = useMutation(gql` mutation createInvoice($amount: Int!) { @@ -55,8 +59,26 @@ export const useAnonymous = (fn) => { } }, [invoice?.id]) - const anonFn = useCallback((amount, ...args) => { - if (me) return fn(amount, ...args) + const anonFn = useCallback(async (amount, ...args) => { + if (me && !options.forceInvoice) { + try { + return await fn(amount, ...args) + } catch (error) { + if (error.toString().includes('insufficient funds')) { + showModal(onClose => { + return ( + { await fn(amount, ...args, invoiceHash) }} + /> + ) + }) + return + } + throw new Error({ message: error.toString() }) + } + } setFnArgs(args) return createInvoice({ variables: { amount } }) }, [fn, setFnArgs, createInvoice]) From 7dda8a1e0131d70da08b1e0e625ff7807c3981d8 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 22 Jul 2023 12:49:01 +0200 Subject: [PATCH 05/54] Fix onSuccess called twice For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: ) is called twice. This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice. --- lib/anonymous.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/anonymous.js b/lib/anonymous.js index 16d955433..663a1c379 100644 --- a/lib/anonymous.js +++ b/lib/anonymous.js @@ -43,12 +43,16 @@ export const useAnonymous = (fn, options = defaultOptions) => { const invoice = data?.createInvoice useEffect(() => { if (invoice) { + // fix for bug where `showModal` runs the code for two modals and thus executes `onSuccess` twice + let called = false showModal(onClose => { setTimeout(async () => { + if (called) return + called = true await fn(satsReceived, ...fnArgs, invoice.hash) onClose() }, 2000) From 6b4b5023f64eb4c780e5a592dcdafd77424f2e93 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 22 Jul 2023 12:56:30 +0200 Subject: [PATCH 06/54] Keep invoice modal open if focus is lost --- components/modal.js | 6 ++++-- lib/anonymous.js | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/components/modal.js b/components/modal.js index 183b762e2..dc54d411c 100644 --- a/components/modal.js +++ b/components/modal.js @@ -21,6 +21,7 @@ export function useShowModal () { export default function useModal () { const [modalContent, setModalContent] = useState(null) + const [modalOptions, setModalOptions] = useState(null) const onClose = useCallback(() => { setModalContent(null) @@ -31,7 +32,7 @@ export default function useModal () { return null } return ( - +
X
{modalContent} @@ -41,7 +42,8 @@ export default function useModal () { }, [modalContent, onClose]) const showModal = useCallback( - (getContent) => { + (getContent, options) => { + setModalOptions(options) setModalContent(getContent(onClose)) }, [onClose] diff --git a/lib/anonymous.js b/lib/anonymous.js index 663a1c379..559854a18 100644 --- a/lib/anonymous.js +++ b/lib/anonymous.js @@ -58,7 +58,7 @@ export const useAnonymous = (fn, options = defaultOptions) => { }, 2000) } } successVerb='received' - /> + />, { keepOpen: true } ) } }, [invoice?.id]) From f0d0d07badef1b3468ecf7a7a4778e0450d6b127 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 22 Jul 2023 15:58:02 +0200 Subject: [PATCH 07/54] Skip anon user during trust calculation --- worker/trust.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/worker/trust.js b/worker/trust.js index 38b372af9..c7119fddf 100644 --- a/worker/trust.js +++ b/worker/trust.js @@ -1,4 +1,5 @@ const math = require('mathjs') +const { ANON_USER_ID } = require('../lib/constants') function trust ({ boss, models }) { return async function () { @@ -158,6 +159,7 @@ async function storeTrust (models, nodeTrust) { // convert nodeTrust into table literal string let values = '' for (const [id, trust] of Object.entries(nodeTrust)) { + if (id === ANON_USER_ID) continue if (values) values += ',' values += `(${id}, ${trust})` } From 85162b6d33e2c37c0cbd8850e05797486c0ca317 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 22 Jul 2023 21:58:04 +0200 Subject: [PATCH 08/54] Add error handling --- lib/anonymous.js | 123 +++++++++++++++++++++++++++++++++++++++++------ lib/time.js | 2 + 2 files changed, 109 insertions(+), 16 deletions(-) diff --git a/lib/anonymous.js b/lib/anonymous.js index 559854a18..06807c1db 100644 --- a/lib/anonymous.js +++ b/lib/anonymous.js @@ -9,8 +9,54 @@ import { useMe } from '../components/me' import { msatsToSats } from './format' import FundError from '../components/fund-error' import { INVOICE } from '../fragments/wallet' +import InvoiceStatus from '../components/invoice-status' +import { sleep } from './time' +import { Button } from 'react-bootstrap' +import { CopyInput } from '../components/form' -const Invoice = ({ id, ...props }) => { +const Contacts = ({ invoiceHash }) => { + const subject = `Support request for payment hash: ${invoiceHash}` + const body = 'Hi, I successfully paid for but the action did not work.' + return ( +
+ Payment hash +
+ +
+ +
+ ) +} + +const Invoice = ({ id, hash, errorCount, repeat, ...props }) => { const { data, loading, error } = useQuery(INVOICE, { pollInterval: 1000, variables: { id } @@ -22,7 +68,26 @@ const Invoice = ({ id, ...props }) => { if (!data || loading) { return } - return + + let errorStatus = 'Something went wrong. Please try again.' + if (errorCount > 1) { + errorStatus = 'Something still went wrong.\nPlease contact admins for support or to request a refund.' + } + return ( + <> + + {errorCount > 0 + ? ( + <> + + {errorCount === 1 + ?
+ : } + + ) + : null} + + ) } const defaultOptions = { @@ -40,25 +105,51 @@ export const useAnonymous = (fn, options = defaultOptions) => { const showModal = useShowModal() const [fnArgs, setFnArgs] = useState() + // fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice + let called = false + let errorCount = 0 + const onConfirmation = useCallback( + onClose => { + called = false + return async ({ id, satsReceived, hash }) => { + if (called) return + called = true + await sleep(2000) + const repeat = () => + fn(satsReceived, ...fnArgs, hash) + .then(onClose) + .catch((error) => { + console.error(error) + errorCount++ + onClose() + showModal(onClose => ( + + ), { keepOpen: true }) + }) + // prevents infinite loop of calling `onConfirmation` + if (errorCount === 0) await repeat() + } + }, [fn, fnArgs] + ) + const invoice = data?.createInvoice useEffect(() => { if (invoice) { - // fix for bug where `showModal` runs the code for two modals and thus executes `onSuccess` twice - let called = false - showModal(onClose => + showModal(onClose => ( { - setTimeout(async () => { - if (called) return - called = true - await fn(satsReceived, ...fnArgs, invoice.hash) - onClose() - }, 2000) - } - } successVerb='received' - />, { keepOpen: true } + hash={invoice.hash} + onConfirmation={onConfirmation(onClose)} + successVerb='received' + /> + ), { keepOpen: true } ) } }, [invoice?.id]) diff --git a/lib/time.js b/lib/time.js index b1f2e4fe9..891482f85 100644 --- a/lib/time.js +++ b/lib/time.js @@ -45,3 +45,5 @@ export function timeLeft (timeStamp) { return parseInt(secondsPast / (3600 * 24)) + ' days' } } + +export const sleep = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms)) From 28ea5ab70ee2035dc9d1ac6160a17d033026f239 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 22 Jul 2023 22:06:04 +0200 Subject: [PATCH 09/54] Skip 'invoice not found' errors --- lib/anonymous.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/anonymous.js b/lib/anonymous.js index 06807c1db..00b62d160 100644 --- a/lib/anonymous.js +++ b/lib/anonymous.js @@ -62,7 +62,9 @@ const Invoice = ({ id, hash, errorCount, repeat, ...props }) => { variables: { id } }) if (error) { - console.log(error) + if (error.message?.includes('invoice not found')) { + return + } return
error
} if (!data || loading) { From 773f658e000524ec8b66150380415450aa8aceb6 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 22 Jul 2023 22:38:33 +0200 Subject: [PATCH 10/54] Remove duplicate insufficient funds handling --- components/item-act.js | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/components/item-act.js b/components/item-act.js index 84884bd3e..e5323fdf9 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -6,8 +6,6 @@ import { useMe } from './me' import UpBolt from '../svgs/bolt.svg' import { amountSchema } from '../lib/validate' import { useAnonymous } from '../lib/anonymous' -import { useShowModal } from './modal' -import FundError from './fund-error' const defaultTips = [100, 1000, 10000, 100000] @@ -43,7 +41,6 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { const inputRef = useRef(null) const me = useMe() const [oValue, setOValue] = useState() - const showModal = useShowModal() useEffect(() => { inputRef.current?.focus() @@ -78,23 +75,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { }} schema={amountSchema} onSubmit={async ({ amount }) => { - try { - await anonAct(amount) - } catch (error) { - if (error.toString().includes('insufficient funds')) { - showModal(onClose => { - return ( - - ) - }) - return - } - throw new Error({ message: error.toString() }) - } + await anonAct(amount) }} > Date: Sat, 22 Jul 2023 22:39:04 +0200 Subject: [PATCH 11/54] Fix insufficient funds error detection --- components/upvote.js | 6 +++--- lib/anonymous.js | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/components/upvote.js b/components/upvote.js index a04e69a4c..95ec48e5d 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -12,6 +12,7 @@ import Overlay from 'react-bootstrap/Overlay' import Popover from 'react-bootstrap/Popover' import { useShowModal } from './modal' import { LightningConsumer } from './lightning' +import { isInsufficientFundsError } from '../lib/anonymous' const getColor = (meSats) => { if (!meSats || meSats <= 10) { @@ -175,9 +176,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } } }) } catch (error) { - if (!timerRef.current) return - - if (error.toString().includes('insufficient funds')) { + if (isInsufficientFundsError(error)) { showModal(onClose => { return ( { ) } +export const isInsufficientFundsError = (error) => { + if (Array.isArray(error)) { + return error.some(({ message }) => message.includes('insufficient funds')) + } + return error.toString().includes('insufficient funds') +} + const defaultOptions = { forceInvoice: false } @@ -161,7 +168,7 @@ export const useAnonymous = (fn, options = defaultOptions) => { try { return await fn(amount, ...args) } catch (error) { - if (error.toString().includes('insufficient funds')) { + if (isInsufficientFundsError(error)) { showModal(onClose => { return ( Date: Sun, 23 Jul 2023 01:50:06 +0200 Subject: [PATCH 12/54] Fix invoice amount for comments --- components/reply.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/components/reply.js b/components/reply.js index 6011fef06..41119a418 100644 --- a/components/reply.js +++ b/components/reply.js @@ -10,7 +10,6 @@ import { commentsViewedAfterComment } from '../lib/new-comments' import { commentSchema } from '../lib/validate' import Info from './info' import { useAnonymous } from '../lib/anonymous' -import { ANON_COMMENT_FEE } from '../lib/constants' export function ReplyOnAnotherPage ({ parentId }) { return ( @@ -130,8 +129,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold text: '' }} schema={commentSchema} - onSubmit={async (values, { resetForm }) => { - await anonCreateComment(ANON_COMMENT_FEE, values, parentId, resetForm) + onSubmit={async ({ cost, ...values }, { resetForm }) => { + await anonCreateComment(cost, values, parentId, resetForm) }} storageKeyPrefix={'reply-' + parentId} > From d186e869e1adb728de7618c1cffc7e0fab12fb88 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 23 Jul 2023 02:19:20 +0200 Subject: [PATCH 13/54] Allow pay per invoice for bounty and job posts --- components/bounty-form.js | 51 +++++++++++++++++++------------ components/job-form.js | 64 ++++++++++++++++++++++----------------- lib/anonymous.js | 6 +++- 3 files changed, 73 insertions(+), 48 deletions(-) diff --git a/components/bounty-form.js b/components/bounty-form.js index 8be19d057..c1c2fb984 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -8,6 +8,8 @@ import InputGroup from 'react-bootstrap/InputGroup' import { bountySchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' +import { useCallback } from 'react' +import { useAnonymous } from '../lib/anonymous' export function BountyForm ({ item, @@ -49,6 +51,33 @@ export function BountyForm ({ ` ) + const submitUpsertBounty = useCallback( + // we ignore the invoice since only stackers can post bounties + // the invoice is only for funding the wallet + async (_, boost, bounty, values, __) => { + const { error } = await upsertBounty({ + variables: { + sub: item?.subName || sub?.name, + id: item?.id, + boost: boost ? Number(boost) : undefined, + bounty: bounty ? Number(bounty) : undefined, + ...values + } + }) + if (error) { + throw new Error({ message: error.toString() }) + } + + if (item) { + await router.push(`/items/${item.id}`) + } else { + const prefix = sub?.name ? `/~${sub.name}` : '' + await router.push(prefix + '/recent') + } + }, [upsertBounty, router]) + + const anonUpsertBounty = useAnonymous(submitUpsertBounty, { requireSession: true }) + return ( { - const { error } = await upsertBounty({ - variables: { - sub: item?.subName || sub?.name, - id: item?.id, - boost: boost ? Number(boost) : undefined, - bounty: bounty ? Number(bounty) : undefined, - ...values - } - }) - if (error) { - throw new Error({ message: error.toString() }) - } - - if (item) { - await router.push(`/items/${item.id}`) - } else { - const prefix = sub?.name ? `/~${sub.name}` : '' - await router.push(prefix + '/recent') - } + (async ({ boost, bounty, cost, ...values }) => { + await anonUpsertBounty(cost, boost, bounty, values) }) } storageKeyPrefix={item ? undefined : 'bounty'} diff --git a/components/job-form.js b/components/job-form.js index b08841843..d91c03233 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -5,7 +5,7 @@ import InputGroup from 'react-bootstrap/InputGroup' import Image from 'react-bootstrap/Image' import BootstrapForm from 'react-bootstrap/Form' import Alert from 'react-bootstrap/Alert' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import Info from './info' import AccordianItem from './accordian-item' import styles from '../styles/post.module.css' @@ -17,6 +17,7 @@ import Avatar from './avatar' import ActionTooltip from './action-tooltip' import { jobSchema } from '../lib/validate' import CancelButton from './cancel-button' +import { useAnonymous } from '../lib/anonymous' function satsMin2Mo (minute) { return minute * 30 * 24 * 60 @@ -50,6 +51,40 @@ export default function JobForm ({ item, sub }) { }` ) + const submitUpsertJob = useCallback( + // we ignore the invoice since only stackers can post bounties + // the invoice is only for funding the wallet + async (_, maxBid, stop, start, values, __) => { + let status + if (start) { + status = 'ACTIVE' + } else if (stop) { + status = 'STOPPED' + } + + const { error } = await upsertJob({ + variables: { + id: item?.id, + sub: item?.subName || sub?.name, + maxBid: Number(maxBid), + status, + logo: Number(logoId), + ...values + } + }) + if (error) { + throw new Error({ message: error.toString() }) + } + + if (item) { + await router.push(`/items/${item.id}`) + } else { + await router.push(`/~${sub.name}/recent`) + } + }, [upsertJob, router]) + + const anonUpsertJob = useAnonymous(submitUpsertJob, { requireSession: true }) + return ( <> { - let status - if (start) { - status = 'ACTIVE' - } else if (stop) { - status = 'STOPPED' - } - - const { error } = await upsertJob({ - variables: { - id: item?.id, - sub: item?.subName || sub?.name, - maxBid: Number(maxBid), - status, - logo: Number(logoId), - ...values - } - }) - if (error) { - throw new Error({ message: error.toString() }) - } - - if (item) { - await router.push(`/items/${item.id}`) - } else { - await router.push(`/~${sub.name}/recent`) - } + await anonUpsertJob(1000, maxBid, stop, start, values) })} >
diff --git a/lib/anonymous.js b/lib/anonymous.js index 28edcd0c6..77f463a7c 100644 --- a/lib/anonymous.js +++ b/lib/anonymous.js @@ -100,7 +100,8 @@ export const isInsufficientFundsError = (error) => { } const defaultOptions = { - forceInvoice: false + forceInvoice: false, + requireSession: false } export const useAnonymous = (fn, options = defaultOptions) => { const me = useMe() @@ -164,6 +165,9 @@ export const useAnonymous = (fn, options = defaultOptions) => { }, [invoice?.id]) const anonFn = useCallback(async (amount, ...args) => { + if (!me && options.requireSession) { + throw new Error('you must be logged in') + } if (me && !options.forceInvoice) { try { return await fn(amount, ...args) From ba04e6522d176405ec78bd6af6dfac58dc5eb601 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 23 Jul 2023 02:24:03 +0200 Subject: [PATCH 14/54] Also strike on payment after short press --- components/upvote.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/upvote.js b/components/upvote.js index 95ec48e5d..d19a839ed 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -11,7 +11,7 @@ import LongPressable from 'react-longpressable' import Overlay from 'react-bootstrap/Overlay' import Popover from 'react-bootstrap/Popover' import { useShowModal } from './modal' -import { LightningConsumer } from './lightning' +import { LightningConsumer, useLightning } from './lightning' import { isInsufficientFundsError } from '../lib/anonymous' const getColor = (meSats) => { @@ -71,6 +71,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } const ref = useRef() const timerRef = useRef(null) const me = useMe() + const strike = useLightning() const [setWalkthrough] = useMutation( gql` mutation setWalkthrough($upvotePopover: Boolean, $tipPopover: Boolean) { @@ -184,6 +185,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } amount={pendingSats} onPayment={async (_, invoiceHash) => { await act({ variables: { ...variables, invoiceHash } }) + strike() }} /> ) From c975bd8ebd5af6fafb2a51000c426a09ff509abf Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 26 Jul 2023 22:07:06 +0200 Subject: [PATCH 15/54] Fix unexpected token 'export' --- lib/constants.js | 89 +++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 46 deletions(-) diff --git a/lib/constants.js b/lib/constants.js index 5c42a6931..467c492d0 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -1,50 +1,47 @@ -export const NOFOLLOW_LIMIT = 100 -export const BOOST_MIN = 5000 -export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 -export const IMAGE_PIXELS_MAX = 35000000 -export const UPLOAD_TYPES_ALLOW = [ - 'image/gif', - 'image/heic', - 'image/png', - 'image/jpeg', - 'image/webp' -] -export const COMMENT_DEPTH_LIMIT = 10 -export const MAX_TITLE_LENGTH = 80 -export const MAX_POLL_CHOICE_LENGTH = 30 -export const ITEM_SPAM_INTERVAL = '10m' -export const MAX_POLL_NUM_CHOICES = 10 -export const MIN_POLL_NUM_CHOICES = 2 -export const ITEM_FILTER_THRESHOLD = 1.2 -export const DONT_LIKE_THIS_COST = 1 -export const COMMENT_TYPE_QUERY = ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks'] - // XXX this is temporary until we have so many subs they have // to be loaded from the server -export const SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs'] -export const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs') -export const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals'] -export const ITEM_SORTS = ['votes', 'comments', 'sats'] -export const WHENS = ['day', 'week', 'month', 'year', 'forever'] - -export const ITEM_TYPES = context => { - if (context === 'jobs') { - return ['posts', 'comments', 'all', 'freebies'] - } +const SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs'] +const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs') - const items = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls'] - if (!context) { - items.push('bios', 'jobs') - } - items.push('freebies') - if (context === 'user') { - items.push('jobs', 'bookmarks') - } - return items +module.exports = { + NOFOLLOW_LIMIT: 100, + BOOST_MIN: 5000, + UPLOAD_SIZE_MAX: 2 * 1024 * 1024, + IMAGE_PIXELS_MAX: 35000000, + UPLOAD_TYPES_ALLOW: [ + 'image/gif', + 'image/heic', + 'image/png', + 'image/jpeg', + 'image/webp' + ], + COMMENT_DEPTH_LIMIT: 10, + MAX_TITLE_LENGTH: 80, + MAX_POLL_CHOICE_LENGTH: 30, + ITEM_SPAM_INTERVAL: '10m', + MAX_POLL_NUM_CHOICES: 10, + MIN_POLL_NUM_CHOICES: 2, + ITEM_FILTER_THRESHOLD: 1.2, + DONT_LIKE_THIS_COST: 1, + COMMENT_TYPE_QUERY: ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks'], + SUBS, + SUBS_NO_JOBS, + USER_SORTS: ['stacked', 'spent', 'comments', 'posts', 'referrals'], + ITEM_SORTS: ['votes', 'comments', 'sats'], + WHENS: ['day', 'week', 'month', 'year', 'forever'], + ITEM_TYPES: context => { + const items = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls'] + if (!context) { + items.push('bios', 'jobs') + } + items.push('freebies') + if (context === 'user') { + items.push('jobs', 'bookmarks') + } + return items + }, + OLD_ITEM_DAYS: 3, + ANON_USER_ID: 27, + ANON_POST_FEE: 1000, + ANON_COMMENT_FEE: 100, } - -export const OLD_ITEM_DAYS = 3 - -export const ANON_USER_ID = 27 -export const ANON_POST_FEE = 1000 -export const ANON_COMMENT_FEE = 100 From 7094f5b552989d5b6da589f3b509d2f9fdc46af9 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 9 Aug 2023 23:49:50 +0200 Subject: [PATCH 16/54] Fix eslint --- lib/apollo.js | 2 +- lib/constants.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/apollo.js b/lib/apollo.js index 23b5be998..573d5e8ed 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -148,7 +148,7 @@ function getClient (uri) { read (meAnonSats, { readField }) { if (typeof window === 'undefined') return null const itemId = readField('id') - return meAnonSats ?? Number(localStorage.getItem(`TIP-item:${itemId}`) || '0') + return meAnonSats ?? Number(window.localStorage.getItem(`TIP-item:${itemId}`) || '0') } } } diff --git a/lib/constants.js b/lib/constants.js index ffc7a4f3b..3e3f09a9d 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -43,7 +43,7 @@ module.exports = { OLD_ITEM_DAYS: 3, ANON_USER_ID: 27, ANON_POST_FEE: 1000, - ANON_COMMENT_FEE: 100, + ANON_COMMENT_FEE: 100 } export const OLD_ITEM_DAYS = 3 From bd59e392b79df6de1c75414771c901cff883be6b Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Aug 2023 00:04:57 +0200 Subject: [PATCH 17/54] Remove unused id param --- pages/invoices/[id].js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/invoices/[id].js b/pages/invoices/[id].js index 2b3a4fd06..edd5c3d24 100644 --- a/pages/invoices/[id].js +++ b/pages/invoices/[id].js @@ -6,7 +6,7 @@ import { useRouter } from 'next/router' import { INVOICE } from '../../fragments/wallet' import { SSR } from '../../lib/constants' -export default function FullInvoice ({ id }) { +export default function FullInvoice () { const router = useRouter() const { data, error } = useQuery(INVOICE, SSR ? {} From 38dbbd5a4f0fa9f23cb3a0d1233f6c5568521416 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Aug 2023 00:07:54 +0200 Subject: [PATCH 18/54] Fix comment copy-paste error --- components/bounty-form.js | 1 - components/job-form.js | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/components/bounty-form.js b/components/bounty-form.js index c1c2fb984..16e6ffddf 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -53,7 +53,6 @@ export function BountyForm ({ const submitUpsertBounty = useCallback( // we ignore the invoice since only stackers can post bounties - // the invoice is only for funding the wallet async (_, boost, bounty, values, __) => { const { error } = await upsertBounty({ variables: { diff --git a/components/job-form.js b/components/job-form.js index d91c03233..b3e04ca51 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -52,8 +52,7 @@ export default function JobForm ({ item, sub }) { ) const submitUpsertJob = useCallback( - // we ignore the invoice since only stackers can post bounties - // the invoice is only for funding the wallet + // we ignore the invoice since only stackers can post jobs async (_, maxBid, stop, start, values, __) => { let status if (start) { From 318088179ac5a416855c8fe2978f0af6f646995a Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Aug 2023 01:45:59 +0200 Subject: [PATCH 19/54] Rename to useInvoiceable --- api/resolvers/item.js | 20 +++- components/bounty-form.js | 6 +- components/discussion-form.js | 6 +- components/fund-error.js | 13 ++- components/invoice.js | 188 ++++++++++++++++++++++++++++- components/item-act.js | 6 +- components/job-form.js | 6 +- components/link-form.js | 6 +- components/poll-form.js | 6 +- components/reply.js | 6 +- components/upvote.js | 3 +- lib/anonymous.js | 214 ---------------------------------- 12 files changed, 237 insertions(+), 243 deletions(-) delete mode 100644 lib/anonymous.js diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 323c74601..8467f811c 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -17,7 +17,6 @@ import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, import { sendUserNotification } from '../webPush' import { proxyImages } from './imgproxy' import { defaultCommentSort } from '../../lib/item' -import { checkInvoice } from '../../lib/anonymous' export async function commentFilterClause (me, models) { let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` @@ -38,6 +37,25 @@ export async function commentFilterClause (me, models) { return clause } +async function checkInvoice (models, invoiceHash, fee) { + const invoice = await models.invoice.findUnique({ + where: { hash: invoiceHash }, + include: { + user: true + } + }) + if (!invoice) { + throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) + } + if (!invoice.msatsReceived) { + throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } }) + } + if (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) { diff --git a/components/bounty-form.js b/components/bounty-form.js index 16e6ffddf..96dd86d73 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -9,7 +9,7 @@ import { bountySchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' import { useCallback } from 'react' -import { useAnonymous } from '../lib/anonymous' +import { useInvoiceable } from './invoice' export function BountyForm ({ item, @@ -75,7 +75,7 @@ export function BountyForm ({ } }, [upsertBounty, router]) - const anonUpsertBounty = useAnonymous(submitUpsertBounty, { requireSession: true }) + const invoiceableUpsertBounty = useInvoiceable(submitUpsertBounty, { requireSession: true }) return ( { - await anonUpsertBounty(cost, boost, bounty, values) + await invoiceableUpsertBounty(cost, boost, bounty, values) }) } storageKeyPrefix={item ? undefined : 'bounty'} diff --git a/components/discussion-form.js b/components/discussion-form.js index 14271f73c..19be6057f 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -13,7 +13,7 @@ import { discussionSchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' import { useCallback } from 'react' -import { useAnonymous } from '../lib/anonymous' +import { useInvoiceable } from './invoice' export function DiscussionForm ({ item, sub, editThreshold, titleLabel = 'title', @@ -53,7 +53,7 @@ export function DiscussionForm ({ } }, [upsertDiscussion, router]) - const anonUpsertDiscussion = useAnonymous(submitUpsertDiscussion) + const invoiceableUpsertDiscussion = useInvoiceable(submitUpsertDiscussion) const [getRelated, { data: relatedData }] = useLazyQuery(gql` ${ITEM_FIELDS} @@ -79,7 +79,7 @@ export function DiscussionForm ({ }} schema={schema} onSubmit={handleSubmit || (async ({ boost, cost, ...values }) => { - await anonUpsertDiscussion(cost, boost, values) + await invoiceableUpsertDiscussion(cost, boost, values) })} storageKeyPrefix={item ? undefined : 'discussion'} > diff --git a/components/fund-error.js b/components/fund-error.js index 01297e8d9..9516f4fa2 100644 --- a/components/fund-error.js +++ b/components/fund-error.js @@ -1,9 +1,9 @@ import Link from 'next/link' import Button from 'react-bootstrap/Button' -import { useAnonymous } from '../lib/anonymous' +import { useInvoiceable } from './invoice' export default function FundError ({ onClose, amount, onPayment }) { - const anonPayment = useAnonymous(onPayment, { forceInvoice: true }) + const createInvoice = useInvoiceable(onPayment, { forceInvoice: true }) return ( <>

you need more sats

@@ -12,8 +12,15 @@ export default function FundError ({ onClose, amount, onPayment }) { or - +
) } + +export const isInsufficientFundsError = (error) => { + if (Array.isArray(error)) { + return error.some(({ message }) => message.includes('insufficient funds')) + } + return error.toString().includes('insufficient funds') +} diff --git a/components/invoice.js b/components/invoice.js index 926641089..5f0526fbc 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -1,6 +1,17 @@ -import AccordianItem from './accordian-item' -import Qr from './qr' +import { useState, useCallback, useEffect } from 'react' +import { useMutation, useQuery } from '@apollo/client' +import { Button } from 'react-bootstrap' +import { gql } from 'graphql-tag' import { numWithUnits } from '../lib/format' +import AccordianItem from './accordian-item' +import Qr, { QrSkeleton } from './qr' +import { CopyInput } from './form' +import { INVOICE } from '../fragments/wallet' +import InvoiceStatus from './invoice-status' +import { useMe } from './me' +import { useShowModal } from './modal' +import { sleep } from '../lib/time' +import FundError, { isInsufficientFundsError } from './fund-error' export function Invoice ({ invoice, onConfirmation, successVerb }) { let variant = 'default' @@ -43,3 +54,176 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) { ) } + +const Contacts = ({ invoiceHash }) => { + const subject = `Support request for payment hash: ${invoiceHash}` + const body = 'Hi, I successfully paid for but the action did not work.' + return ( +
+ Payment hash +
+ +
+ +
+ ) +} + +const ActionInvoice = ({ id, hash, errorCount, repeat, ...props }) => { + const { data, loading, error } = useQuery(INVOICE, { + pollInterval: 1000, + variables: { id } + }) + if (error) { + if (error.message?.includes('invoice not found')) { + return + } + return
error
+ } + if (!data || loading) { + return + } + + let errorStatus = 'Something went wrong. Please try again.' + if (errorCount > 1) { + errorStatus = 'Something still went wrong.\nPlease contact admins for support or to request a refund.' + } + return ( + <> + + {errorCount > 0 + ? ( + <> + + {errorCount === 1 + ?
+ : } + + ) + : null} + + ) +} + +const defaultOptions = { + forceInvoice: false, + requireSession: false +} +export const useInvoiceable = (fn, options = defaultOptions) => { + const me = useMe() + const [createInvoice, { data }] = useMutation(gql` + mutation createInvoice($amount: Int!) { + createInvoice(amount: $amount) { + id + hash + } + }`) + const showModal = useShowModal() + const [fnArgs, setFnArgs] = useState() + + // fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice + let called = false + let errorCount = 0 + const onConfirmation = useCallback( + onClose => { + called = false + return async ({ id, satsReceived, hash }) => { + if (called) return + called = true + await sleep(2000) + const repeat = () => + fn(satsReceived, ...fnArgs, hash) + .then(onClose) + .catch((error) => { + console.error(error) + errorCount++ + onClose() + showModal(onClose => ( + + ), { keepOpen: true }) + }) + // prevents infinite loop of calling `onConfirmation` + if (errorCount === 0) await repeat() + } + }, [fn, fnArgs] + ) + + const invoice = data?.createInvoice + useEffect(() => { + if (invoice) { + showModal(onClose => ( + + ), { keepOpen: true } + ) + } + }, [invoice?.id]) + + const actionFn = useCallback(async (amount, ...args) => { + if (!me && options.requireSession) { + throw new Error('you must be logged in') + } + if (me && !options.forceInvoice) { + try { + return await fn(amount, ...args) + } catch (error) { + if (isInsufficientFundsError(error)) { + showModal(onClose => { + return ( + { await fn(amount, ...args, invoiceHash) }} + /> + ) + }) + return + } + throw new Error({ message: error.toString() }) + } + } + setFnArgs(args) + return createInvoice({ variables: { amount } }) + }, [fn, setFnArgs, createInvoice]) + + return actionFn +} diff --git a/components/item-act.js b/components/item-act.js index e5323fdf9..860ae0176 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -5,7 +5,7 @@ import { Form, Input, SubmitButton } from './form' import { useMe } from './me' import UpBolt from '../svgs/bolt.svg' import { amountSchema } from '../lib/validate' -import { useAnonymous } from '../lib/anonymous' +import { useInvoiceable } from './invoice' const defaultTips = [100, 1000, 10000, 100000] @@ -65,7 +65,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { onClose() }, [act, onClose, strike, itemId]) - const anonAct = useAnonymous(submitAct) + const invoiceableAct = useInvoiceable(submitAct) return ( { - await anonAct(amount) + await invoiceableAct(amount) }} > @@ -102,7 +102,7 @@ export default function JobForm ({ item, sub }) { schema={jobSchema} storageKeyPrefix={storageKeyPrefix} onSubmit={(async ({ maxBid, stop, start, ...values }) => { - await anonUpsertJob(1000, maxBid, stop, start, values) + await invoiceableUpsertJob(1000, maxBid, stop, start, values) })} >
diff --git a/components/link-form.js b/components/link-form.js index fa30ed830..65eae961b 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -14,7 +14,7 @@ import { linkSchema } from '../lib/validate' import Moon from '../svgs/moon-fill.svg' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' -import { useAnonymous } from '../lib/anonymous' +import { useInvoiceable } from './invoice' export function LinkForm ({ item, sub, editThreshold, children }) { const router = useRouter() @@ -90,7 +90,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) { } }, [upsertLink, router]) - const anonUpsertLink = useAnonymous(submitUpsertLink) + const invoiceableUpsertLink = useInvoiceable(submitUpsertLink) useEffect(() => { if (data?.pageTitleAndUnshorted?.title) { @@ -119,7 +119,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) { }} schema={schema} onSubmit={async ({ boost, title, cost, ...values }) => { - await anonUpsertLink(cost, boost, title, values) + await invoiceableUpsertLink(cost, boost, title, values) }} storageKeyPrefix={item ? undefined : 'link'} > diff --git a/components/poll-form.js b/components/poll-form.js index 5cf26156a..402fb9abd 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -11,7 +11,7 @@ import { pollSchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' import { useCallback } from 'react' -import { useAnonymous } from '../lib/anonymous' +import { useInvoiceable } from './invoice' export function PollForm ({ item, sub, editThreshold, children }) { const router = useRouter() @@ -54,7 +54,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { } }, [upsertPoll, router]) - const anonUpsertPoll = useAnonymous(submitUpsertPoll) + const invoiceableUpsertPoll = useInvoiceable(submitUpsertPoll) const initialOptions = item?.poll?.options.map(i => i.option) @@ -69,7 +69,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { }} schema={schema} onSubmit={async ({ boost, title, options, cost, ...values }) => { - await anonUpsertPoll(cost, boost, title, options, values) + await invoiceableUpsertPoll(cost, boost, title, options, values) }} storageKeyPrefix={item ? undefined : 'poll'} > diff --git a/components/reply.js b/components/reply.js index 41119a418..e748f8b18 100644 --- a/components/reply.js +++ b/components/reply.js @@ -9,7 +9,7 @@ import FeeButton from './fee-button' import { commentsViewedAfterComment } from '../lib/new-comments' import { commentSchema } from '../lib/validate' import Info from './info' -import { useAnonymous } from '../lib/anonymous' +import { useInvoiceable } from './invoice' export function ReplyOnAnotherPage ({ parentId }) { return ( @@ -101,7 +101,7 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold setReply(replyOpen || false) }, [createComment, setReply]) - const anonCreateComment = useAnonymous(submitComment) + const invoiceableCreateComment = useInvoiceable(submitComment) const replyInput = useRef(null) useEffect(() => { @@ -130,7 +130,7 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold }} schema={commentSchema} onSubmit={async ({ cost, ...values }, { resetForm }) => { - await anonCreateComment(cost, values, parentId, resetForm) + await invoiceableCreateComment(cost, values, parentId, resetForm) }} storageKeyPrefix={'reply-' + parentId} > diff --git a/components/upvote.js b/components/upvote.js index a24f477b3..9965a98a5 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -1,7 +1,7 @@ import UpBolt from '../svgs/bolt.svg' import styles from './upvote.module.css' import { gql, useMutation } from '@apollo/client' -import FundError from './fund-error' +import FundError, { isInsufficientFundsError } from './fund-error' import ActionTooltip from './action-tooltip' import ItemAct from './item-act' import { useMe } from './me' @@ -12,7 +12,6 @@ import Overlay from 'react-bootstrap/Overlay' import Popover from 'react-bootstrap/Popover' import { useShowModal } from './modal' import { LightningConsumer, useLightning } from './lightning' -import { isInsufficientFundsError } from '../lib/anonymous' import { numWithUnits } from '../lib/format' const getColor = (meSats) => { diff --git a/lib/anonymous.js b/lib/anonymous.js deleted file mode 100644 index 77f463a7c..000000000 --- a/lib/anonymous.js +++ /dev/null @@ -1,214 +0,0 @@ -import { useMutation, useQuery } from '@apollo/client' -import { GraphQLError } from 'graphql' -import { gql } from 'graphql-tag' -import { useCallback, useEffect, useState } from 'react' -import { useShowModal } from '../components/modal' -import { Invoice as QrInvoice } from '../components/invoice' -import { QrSkeleton } from '../components/qr' -import { useMe } from '../components/me' -import { msatsToSats } from './format' -import FundError from '../components/fund-error' -import { INVOICE } from '../fragments/wallet' -import InvoiceStatus from '../components/invoice-status' -import { sleep } from './time' -import { Button } from 'react-bootstrap' -import { CopyInput } from '../components/form' - -const Contacts = ({ invoiceHash }) => { - const subject = `Support request for payment hash: ${invoiceHash}` - const body = 'Hi, I successfully paid for but the action did not work.' - return ( -
- Payment hash -
- -
- -
- ) -} - -const Invoice = ({ id, hash, errorCount, repeat, ...props }) => { - const { data, loading, error } = useQuery(INVOICE, { - pollInterval: 1000, - variables: { id } - }) - if (error) { - if (error.message?.includes('invoice not found')) { - return - } - return
error
- } - if (!data || loading) { - return - } - - let errorStatus = 'Something went wrong. Please try again.' - if (errorCount > 1) { - errorStatus = 'Something still went wrong.\nPlease contact admins for support or to request a refund.' - } - return ( - <> - - {errorCount > 0 - ? ( - <> - - {errorCount === 1 - ?
- : } - - ) - : null} - - ) -} - -export const isInsufficientFundsError = (error) => { - if (Array.isArray(error)) { - return error.some(({ message }) => message.includes('insufficient funds')) - } - return error.toString().includes('insufficient funds') -} - -const defaultOptions = { - forceInvoice: false, - requireSession: false -} -export const useAnonymous = (fn, options = defaultOptions) => { - const me = useMe() - const [createInvoice, { data }] = useMutation(gql` - mutation createInvoice($amount: Int!) { - createInvoice(amount: $amount) { - id - hash - } - }`) - const showModal = useShowModal() - const [fnArgs, setFnArgs] = useState() - - // fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice - let called = false - let errorCount = 0 - const onConfirmation = useCallback( - onClose => { - called = false - return async ({ id, satsReceived, hash }) => { - if (called) return - called = true - await sleep(2000) - const repeat = () => - fn(satsReceived, ...fnArgs, hash) - .then(onClose) - .catch((error) => { - console.error(error) - errorCount++ - onClose() - showModal(onClose => ( - - ), { keepOpen: true }) - }) - // prevents infinite loop of calling `onConfirmation` - if (errorCount === 0) await repeat() - } - }, [fn, fnArgs] - ) - - const invoice = data?.createInvoice - useEffect(() => { - if (invoice) { - showModal(onClose => ( - - ), { keepOpen: true } - ) - } - }, [invoice?.id]) - - const anonFn = useCallback(async (amount, ...args) => { - if (!me && options.requireSession) { - throw new Error('you must be logged in') - } - if (me && !options.forceInvoice) { - try { - return await fn(amount, ...args) - } catch (error) { - if (isInsufficientFundsError(error)) { - showModal(onClose => { - return ( - { await fn(amount, ...args, invoiceHash) }} - /> - ) - }) - return - } - throw new Error({ message: error.toString() }) - } - } - setFnArgs(args) - return createInvoice({ variables: { amount } }) - }, [fn, setFnArgs, createInvoice]) - - return anonFn -} - -export const checkInvoice = async (models, invoiceHash, fee) => { - const invoice = await models.invoice.findUnique({ - where: { hash: invoiceHash }, - include: { - user: true - } - }) - if (!invoice) { - throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) - } - if (!invoice.msatsReceived) { - throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } }) - } - if (msatsToSats(invoice.msatsReceived) < fee) { - throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } }) - } - return invoice -} From 9bc513846138ca7d654b04a8be0a997bbdf2e714 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Aug 2023 02:33:04 +0200 Subject: [PATCH 20/54] Fix unexpected token 'export' --- lib/constants.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/constants.js b/lib/constants.js index 3e3f09a9d..d8d9d8e32 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -43,8 +43,6 @@ module.exports = { OLD_ITEM_DAYS: 3, ANON_USER_ID: 27, ANON_POST_FEE: 1000, - ANON_COMMENT_FEE: 100 + ANON_COMMENT_FEE: 100, + SSR: typeof window === 'undefined' } - -export const OLD_ITEM_DAYS = 3 -export const SSR = typeof window === 'undefined' From 4fe1d416de98124d7f32395d5d8ff64bd23b0218 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Aug 2023 04:13:09 +0200 Subject: [PATCH 21/54] Fix onConfirmation called at every render --- components/invoice.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 5f0526fbc..d2f4bca51 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -20,7 +20,6 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) { if (invoice.confirmedAt) { variant = 'confirmed' status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}` - onConfirmation?.(invoice) webLn = false } else if (invoice.cancelled) { variant = 'failed' @@ -32,6 +31,12 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) { webLn = false } + useEffect(() => { + if (invoice.confirmedAt) { + onConfirmation?.(invoice) + } + }, [invoice.confirmedAt]) + const { nostr } = invoice return ( @@ -150,14 +155,10 @@ export const useInvoiceable = (fn, options = defaultOptions) => { const [fnArgs, setFnArgs] = useState() // fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice - let called = false let errorCount = 0 const onConfirmation = useCallback( onClose => { - called = false return async ({ id, satsReceived, hash }) => { - if (called) return - called = true await sleep(2000) const repeat = () => fn(satsReceived, ...fnArgs, hash) From bb2212d51efdf20a8c4957c367b2c3c52de22ffd Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Aug 2023 07:10:05 +0200 Subject: [PATCH 22/54] Add invoice HMAC This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN. Only the user which created the invoice knows the HMAC and thus can use the invoice hash. --- .env.sample | 1 + api/resolvers/item.js | 36 ++++++++++++++++++++++------------- api/resolvers/wallet.js | 13 ++++++++++++- api/typeDefs/item.js | 10 +++++----- api/typeDefs/wallet.js | 1 + components/bounty-form.js | 2 +- components/discussion-form.js | 8 ++++---- components/invoice.js | 23 ++++++++++++++-------- components/item-act.js | 5 +++-- components/job-form.js | 2 +- components/link-form.js | 8 ++++---- components/poll-form.js | 9 +++++---- components/reply.js | 8 ++++---- components/upvote.js | 4 ++-- 14 files changed, 81 insertions(+), 49 deletions(-) diff --git a/.env.sample b/.env.sample index cab864f67..38da8e7fd 100644 --- a/.env.sample +++ b/.env.sample @@ -47,6 +47,7 @@ PUBLIC_URL=http://localhost:3000 LND_CONNECT_ADDRESS=03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcbe78f02@xhlmkj7mfrl6ejnczfwl2vqik3xim6wzmurc2vlyfoqw2sasaocgpuad.onion:9735 NEXTAUTH_SECRET=3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI JWT_SIGNING_PRIVATE_KEY={"kty":"oct","kid":"FvD__hmeKoKHu2fKjUrWbRKfhjimIM4IKshyrJG4KSM","alg":"HS512","k":"3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"} +INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c91 # imgproxy NEXT_PUBLIC_IMGPROXY_URL= diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 8467f811c..576737850 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -17,6 +17,7 @@ import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, import { sendUserNotification } from '../webPush' import { proxyImages } from './imgproxy' import { defaultCommentSort } from '../../lib/item' +import { createHmac } from './wallet' export async function commentFilterClause (me, models) { let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` @@ -37,9 +38,17 @@ export async function commentFilterClause (me, models) { return clause } -async function checkInvoice (models, invoiceHash, fee) { +async function checkInvoice (models, hash, hmac, fee) { + 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: invoiceHash }, + where: { hash }, include: { user: true } @@ -590,7 +599,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash }) + return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac }) } }, upsertDiscussion: async (parent, args, { me, models }) => { @@ -601,7 +610,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash }) + return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac }) } }, upsertBounty: async (parent, args, { me, models }) => { @@ -616,11 +625,11 @@ export default { } }, upsertPoll: async (parent, { id, ...data }, { me, models }) => { - const { sub, forward, boost, title, text, options, invoiceHash } = data + const { sub, forward, boost, title, text, options, invoiceHash, invoiceHmac } = data let author = me const trx = [] if (!me && invoiceHash) { - const invoice = await checkInvoice(models, invoiceHash, ANON_POST_FEE) + const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, ANON_POST_FEE) author = invoice.user trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } @@ -707,7 +716,7 @@ export default { }, createComment: async (parent, data, { me, models }) => { await ssValidate(commentSchema, data) - const item = await createItem(parent, data, { me, models, invoiceHash: data.invoiceHash }) + const item = await createItem(parent, data, { me, models, invoiceHash: data.invoiceHash, invoiceHmac: data.invoiceHmac }) // fetch user to get up-to-date name const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) @@ -740,7 +749,7 @@ export default { return id }, - act: async (parent, { id, sats, invoiceHash }, { me, models }) => { + act: async (parent, { id, sats, invoiceHash, invoiceHmac }, { me, models }) => { // need to make sure we are logged in if (!me && !invoiceHash) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) @@ -749,8 +758,9 @@ export default { await ssValidate(amountSchema, { amount: sats }) let user = me + let invoice if (!me && invoiceHash) { - const invoice = await checkInvoice(models, invoiceHash, sats) + invoice = await checkInvoice(models, invoiceHash, invoiceHmac, sats) user = invoice.user } @@ -766,8 +776,8 @@ export default { const calls = [ models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` ] - if (!me && invoiceHash) { - calls.push(models.invoice.delete({ where: { hash: invoiceHash } })) + if (invoice) { + calls.push(models.invoice.delete({ where: { hash: invoice.hash } })) } const [{ item_act: vote }] = await serialize(models, ...calls) @@ -1093,11 +1103,11 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo return item } -const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash }) => { +const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash, invoiceHmac }) => { let author = me const trx = [] if (!me && invoiceHash) { - const invoice = await checkInvoice(models, invoiceHash, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) + const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) author = invoice.user trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index f1b570035..d9cc5fa18 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,5 +1,6 @@ import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service' import { GraphQLError } from 'graphql' +import crypto from 'crypto' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import lnpr from 'bolt11' @@ -40,6 +41,11 @@ export async function getInvoice (parent, { id }, { me, models }) { return inv } +export function createHmac (hash) { + const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex') + return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') +} + export default { Query: { invoice: getInvoice, @@ -220,7 +226,12 @@ export default { models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description})`) - return inv + // the HMAC is only returned during invoice creation + // this makes sure that only the person who created this invoice + // has access to the HMAC + const hmac = createHmac(inv.hash) + + return { ...inv, hmac } } catch (error) { console.log(error) throw error diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 0ca7f821e..5852d5294 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -26,16 +26,16 @@ export default gql` bookmarkItem(id: ID): Item subscribeItem(id: ID): Item deleteItem(id: ID): Item - upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceHash: String): Item! - upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceHash: String): Item! + upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item! + upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item! upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item! upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! - upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceHash: String): Item! - createComment(text: String!, parentId: ID!, invoiceHash: String): Item! + upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item! + createComment(text: String!, parentId: ID!, invoiceHash: String, invoiceHmac: String): Item! updateComment(id: ID!, text: String!): Item! dontLikeThis(id: ID!): Boolean! - act(id: ID!, sats: Int, invoiceHash: String): ItemActResult! + act(id: ID!, sats: Int, invoiceHash: String, invoiceHmac: String): ItemActResult! pollVote(id: ID!): ID! } diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 3cc3a9f63..2bde43e04 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -24,6 +24,7 @@ export default gql` confirmedAt: Date satsReceived: Int nostr: JSONObject + hmac: String } type Withdrawl { diff --git a/components/bounty-form.js b/components/bounty-form.js index 96dd86d73..666869e72 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -53,7 +53,7 @@ export function BountyForm ({ const submitUpsertBounty = useCallback( // we ignore the invoice since only stackers can post bounties - async (_, boost, bounty, values, __) => { + async (_, boost, bounty, values, ...__) => { const { error } = await upsertBounty({ variables: { sub: item?.subName || sub?.name, diff --git a/components/discussion-form.js b/components/discussion-form.js index 19be6057f..769380a50 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -29,17 +29,17 @@ export function DiscussionForm ({ // const me = useMe() const [upsertDiscussion] = useMutation( gql` - mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceHash: String) { - upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { + mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) { + upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { id } }` ) const submitUpsertDiscussion = useCallback( - async (_, boost, values, invoiceHash) => { + async (_, boost, values, invoiceHash, invoiceHmac) => { const { error } = await upsertDiscussion({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceHash } + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceHash, invoiceHmac } }) if (error) { throw new Error({ message: error.toString() }) diff --git a/components/invoice.js b/components/invoice.js index d2f4bca51..678db5e92 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -60,7 +60,7 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) { ) } -const Contacts = ({ invoiceHash }) => { +const Contacts = ({ invoiceHash, invoiceHmac }) => { const subject = `Support request for payment hash: ${invoiceHash}` const body = 'Hi, I successfully paid for but the action did not work.' return ( @@ -69,6 +69,10 @@ const Contacts = ({ invoiceHash }) => {
+ Payment HMAC +
+ +
{ ) } -const ActionInvoice = ({ id, hash, errorCount, repeat, ...props }) => { +const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => { const { data, loading, error } = useQuery(INVOICE, { pollInterval: 1000, variables: { id } @@ -130,7 +134,7 @@ const ActionInvoice = ({ id, hash, errorCount, repeat, ...props }) => { {errorCount === 1 ?
- : } + : } ) : null} @@ -149,6 +153,7 @@ export const useInvoiceable = (fn, options = defaultOptions) => { createInvoice(amount: $amount) { id hash + hmac } }`) const showModal = useShowModal() @@ -157,11 +162,11 @@ export const useInvoiceable = (fn, options = defaultOptions) => { // fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice let errorCount = 0 const onConfirmation = useCallback( - onClose => { + (onClose, hmac) => { return async ({ id, satsReceived, hash }) => { await sleep(2000) const repeat = () => - fn(satsReceived, ...fnArgs, hash) + fn(satsReceived, ...fnArgs, hash, hmac) .then(onClose) .catch((error) => { console.error(error) @@ -171,7 +176,8 @@ export const useInvoiceable = (fn, options = defaultOptions) => { { ), { keepOpen: true } @@ -213,7 +220,7 @@ export const useInvoiceable = (fn, options = defaultOptions) => { { await fn(amount, ...args, invoiceHash) }} + onPayment={async (_, invoiceHash, invoiceHmac) => { await fn(amount, ...args, invoiceHash, invoiceHmac) }} /> ) }) diff --git a/components/item-act.js b/components/item-act.js index 860ae0176..6c5bc4ece 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -47,7 +47,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { }, [onClose, itemId]) const submitAct = useCallback( - async (amount, invoiceHash) => { + async (amount, invoiceHash, invoiceHmac) => { if (!me) { const storageKey = `TIP-item:${itemId}` const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') @@ -57,7 +57,8 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { variables: { id: itemId, sats: Number(amount), - invoiceHash + invoiceHash, + invoiceHmac } }) await strike() diff --git a/components/job-form.js b/components/job-form.js index 21d9990c7..8a8027f1b 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -53,7 +53,7 @@ export default function JobForm ({ item, sub }) { const submitUpsertJob = useCallback( // we ignore the invoice since only stackers can post jobs - async (_, maxBid, stop, start, values, __) => { + async (_, maxBid, stop, start, values, ...__) => { let status if (start) { status = 'ACTIVE' diff --git a/components/link-form.js b/components/link-form.js index 65eae961b..b9fa59127 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -67,17 +67,17 @@ export function LinkForm ({ item, sub, editThreshold, children }) { const [upsertLink] = useMutation( gql` - mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceHash: String) { - upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { + mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) { + upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { id } }` ) const submitUpsertLink = useCallback( - async (_, boost, title, values, invoiceHash) => { + async (_, boost, title, values, invoiceHash, invoiceHmac) => { const { error } = await upsertLink({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceHash, ...values } + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceHash, invoiceHmac, ...values } }) if (error) { throw new Error({ message: error.toString() }) diff --git a/components/poll-form.js b/components/poll-form.js index 402fb9abd..91022c1c5 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -21,16 +21,16 @@ export function PollForm ({ item, sub, editThreshold, children }) { const [upsertPoll] = useMutation( gql` mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, - $options: [String!]!, $boost: Int, $forward: String, $invoiceHash: String) { + $options: [String!]!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) { upsertPoll(sub: $sub, id: $id, title: $title, text: $text, - options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { + options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { id } }` ) const submitUpsertPoll = useCallback( - async (_, boost, title, options, values, invoiceHash) => { + async (_, boost, title, options, values, invoiceHash, invoiceHmac) => { const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) const { error } = await upsertPoll({ variables: { @@ -40,7 +40,8 @@ export function PollForm ({ item, sub, editThreshold, children }) { title: title.trim(), options: optionsFiltered, ...values, - invoiceHash + invoiceHash, + invoiceHmac } }) if (error) { diff --git a/components/reply.js b/components/reply.js index e748f8b18..e52d89717 100644 --- a/components/reply.js +++ b/components/reply.js @@ -46,8 +46,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold const [createComment] = useMutation( gql` ${COMMENTS} - mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String) { - createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash) { + mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) { + createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { ...CommentFields comments { ...CommentsRecursive @@ -92,8 +92,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold ) const submitComment = useCallback( - async (_, values, parentId, resetForm, invoiceHash) => { - const { error } = await createComment({ variables: { ...values, parentId, invoiceHash } }) + async (_, values, parentId, resetForm, invoiceHash, invoiceHmac) => { + const { error } = await createComment({ variables: { ...values, parentId, invoiceHash, invoiceHmac } }) if (error) { throw new Error({ message: error.toString() }) } diff --git a/components/upvote.js b/components/upvote.js index 9965a98a5..5347eb20d 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -110,8 +110,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } const [act] = useMutation( gql` - mutation act($id: ID!, $sats: Int!, $invoiceHash: String) { - act(id: $id, sats: $sats, invoiceHash: $invoiceHash) { + mutation act($id: ID!, $sats: Int!, $invoiceHash: String, $invoiceHmac: String) { + act(id: $id, sats: $sats, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { sats } }`, { From 081c5fef0b972c5c2137824480aba03fae3e7184 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 14:41:56 -0500 Subject: [PATCH 23/54] make anon posting less hidden, add anon info button explainer --- components/fee-button.js | 30 ++++++++++++++++++++++++++++-- components/fee-button.module.css | 9 +++++++++ components/header.js | 18 ++++++++++++++++-- components/header.module.css | 16 ++++++++++++++++ components/invoice-status.js | 6 +++--- components/invoice.js | 8 +++++--- svgs/spy-fill.svg | 1 + 7 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 svgs/spy-fill.svg diff --git a/components/fee-button.js b/components/fee-button.js index 05ed3f208..3f6939905 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -8,6 +8,9 @@ import { useFormikContext } from 'formik' import { SSR, ANON_COMMENT_FEE, ANON_POST_FEE } from '../lib/constants' import { numWithUnits } from '../lib/format' import { useMe } from './me' +import AnonIcon from '../svgs/spy-fill.svg' +import { useShowModal } from './modal' +import Link from 'next/link' function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { return ( @@ -43,6 +46,28 @@ function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { ) } +function AnonInfo () { + const showModal = useShowModal() + + return ( + + showModal(onClose => +
Hey sneaky! You are posting without an account. +
    +
  1. You'll pay by invoice
  2. +
  3. Your content will be content-joined (get it?!) under the @anon account
  4. +
  5. Any sats your content earns will go toward rewards
  6. +
  7. We won't be able to notify about replies
  8. +
+
) + } + /> + ) +} + export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow, disabled }) { const me = useMe() baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) @@ -61,10 +86,11 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, const show = alwaysShow || !formik?.isSubmitting return ( -
+
- {text}{cost > baseFee && show && {numWithUnits(cost, { abbreviate: false })}} + {text}{cost > 1 && show && {numWithUnits(cost, { abbreviate: false })}} + {!me && } {cost > baseFee && show && diff --git a/components/fee-button.module.css b/components/fee-button.module.css index c2d40c542..09fdc2dcf 100644 --- a/components/fee-button.module.css +++ b/components/fee-button.module.css @@ -6,6 +6,15 @@ width: 100%; } +.feeButton { + display: flex; + align-items: center; +} + +.feeButton small { + font-weight: 400; +} + .receipt td { padding: .25rem .1rem; background-color: var(--theme-inputBg); diff --git a/components/header.js b/components/header.js index 7525f656c..a91bb34a8 100644 --- a/components/header.js +++ b/components/header.js @@ -23,6 +23,7 @@ import BackArrow from '../svgs/arrow-left-line.svg' import { SSR, SUBS } from '../lib/constants' import { useLightning } from './lightning' import { HAS_NOTIFICATIONS } from '../fragments/notifications' +import AnonIcon from '../svgs/spy-fill.svg' function WalletSummary ({ me }) { if (!me) return null @@ -216,9 +217,22 @@ function NavItems ({ className, sub, prefix }) { } function PostItem ({ className, prefix }) { + const me = useMe() + + if (me) { + return ( + + post + + ) + } + return ( - - post + + post ) } diff --git a/components/header.module.css b/components/header.module.css index a5340c94f..6abd5e101 100644 --- a/components/header.module.css +++ b/components/header.module.css @@ -9,6 +9,22 @@ color: var(--theme-brandColor) !important; } +.postAnon { + border-width: 2px; +} + +.postAnon svg { + fill: var(--bs-grey-darkmode); +} + +.postAnon:hover, .postAnon:active, .postAnon:focus-visible { + color: var(--bs-white) !important; +} + +.postAnon:hover svg, .postAnon:active svg, .postAnon:focus-visible svg { + fill: var(--bs-white); +} + .navLinkButton { border: 2px solid; padding: 0.2rem .9rem !important; diff --git a/components/invoice-status.js b/components/invoice-status.js index bd1272089..271599ff4 100644 --- a/components/invoice-status.js +++ b/components/invoice-status.js @@ -4,7 +4,7 @@ import ThumbDown from '../svgs/thumb-down-fill.svg' function InvoiceDefaultStatus ({ status }) { return ( -
+
{status}
@@ -13,7 +13,7 @@ function InvoiceDefaultStatus ({ status }) { function InvoiceConfirmedStatus ({ status }) { return ( -
+
{status}
@@ -22,7 +22,7 @@ function InvoiceConfirmedStatus ({ status }) { function InvoiceFailedStatus ({ status }) { return ( -
+
{status}
diff --git a/components/invoice.js b/components/invoice.js index 678db5e92..06d5c670f 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -64,7 +64,7 @@ const Contacts = ({ invoiceHash, invoiceHmac }) => { const subject = `Support request for payment hash: ${invoiceHash}` const body = 'Hi, I successfully paid for but the action did not work.' return ( -
+
Payment hash
@@ -121,7 +121,7 @@ const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => { return } - let errorStatus = 'Something went wrong. Please try again.' + let errorStatus = 'Something went wrong trying to perform the action after payment.' if (errorCount > 1) { errorStatus = 'Something still went wrong.\nPlease contact admins for support or to request a refund.' } @@ -131,7 +131,9 @@ const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => { {errorCount > 0 ? ( <> - +
+ +
{errorCount === 1 ?
: } diff --git a/svgs/spy-fill.svg b/svgs/spy-fill.svg new file mode 100644 index 000000000..eb84073db --- /dev/null +++ b/svgs/spy-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file From 35760e1655ea0ebbe64bedae62f877922cc35ad0 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Aug 2023 22:57:45 +0200 Subject: [PATCH 24/54] Fix anon users can't zap other anon users --- api/resolvers/item.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 576737850..cb3213a0a 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -764,13 +764,15 @@ export default { user = invoice.user } - // disallow self tips - const [item] = await models.$queryRawUnsafe(` - ${SELECT} - FROM "Item" - WHERE id = $1 AND "userId" = $2`, Number(id), user.id) - if (item) { - throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } }) + // disallow self tips except anons + if (user.id !== ANON_USER_ID) { + const [item] = await models.$queryRawUnsafe(` + ${SELECT} + FROM "Item" + WHERE id = $1 AND "userId" = $2`, Number(id), user.id) + if (item) { + throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } }) + } } const calls = [ From 49736e8d3cb14890f2d72c55c47281530b60697c Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Aug 2023 23:04:06 +0200 Subject: [PATCH 25/54] Always show repeat and contacts on action error --- components/invoice.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 06d5c670f..5d2e7ed6d 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -134,9 +134,8 @@ const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => {
- {errorCount === 1 - ?
- : } +
+ ) : null} From 2fbf1e4cc3fb26f82813b19470a8ef7a2194fd02 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 11 Aug 2023 00:35:43 +0200 Subject: [PATCH 26/54] Keep track of modal stack --- components/modal.js | 22 ++++++++++++++++++++-- styles/globals.scss | 13 +++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/components/modal.js b/components/modal.js index dc54d411c..5cea3ca84 100644 --- a/components/modal.js +++ b/components/modal.js @@ -1,5 +1,6 @@ import { createContext, useCallback, useContext, useMemo, useState } from 'react' import Modal from 'react-bootstrap/Modal' +import BackArrow from '../svgs/arrow-left-line.svg' export const ShowModalContext = createContext(() => null) @@ -22,9 +23,20 @@ export function useShowModal () { export default function useModal () { const [modalContent, setModalContent] = useState(null) const [modalOptions, setModalOptions] = useState(null) + const [modalStack, setModalStack] = useState([]) + + const onBack = useCallback(() => { + if (modalStack.length === 0) { + return setModalContent(null) + } + const previousModalContent = modalStack[modalStack.length - 1] + setModalStack(modalStack.slice(0, -1)) + return setModalContent(previousModalContent) + }, [modalStack, setModalStack]) const onClose = useCallback(() => { setModalContent(null) + setModalStack([]) }, []) const modal = useMemo(() => { @@ -33,7 +45,10 @@ export default function useModal () { } return ( -
X
+
+ {modalStack.length > 0 ?
: null} +
X
+
{modalContent} @@ -43,10 +58,13 @@ export default function useModal () { const showModal = useCallback( (getContent, options) => { + if (modalContent) { + setModalStack(stack => ([...stack, modalContent])) + } setModalOptions(options) setModalContent(getContent(onClose)) }, - [onClose] + [modalContent, onClose] ) return [modal, showModal] diff --git a/styles/globals.scss b/styles/globals.scss index 9650e5f01..00210ddff 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -175,11 +175,20 @@ $grid-gutter-width: 2rem; margin-bottom: 0 !important; } -.modal-close { +.modal-btn { cursor: pointer; display: flex; - margin-left: auto; padding-top: 1rem; + align-items: center; +} + +.modal-back { + margin-right: auto; + padding-left: 1.5rem; +} + +.modal-close { + margin-left: auto; padding-right: 1.5rem; font-family: "lightning"; font-size: 150%; From 46274fba4fcfa6b7fd64c18317d45a6d574b54b6 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 17:33:47 -0500 Subject: [PATCH 27/54] give anon an icon --- components/cowboy-hat.js | 2 +- components/item-info.js | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/components/cowboy-hat.js b/components/cowboy-hat.js index fda60d27d..ac511f712 100644 --- a/components/cowboy-hat.js +++ b/components/cowboy-hat.js @@ -26,7 +26,7 @@ export default function CowboyHat ({ user, badge, className = 'ms-1', height = 1 ) } -function HatTooltip ({ children, overlayText, placement }) { +export function HatTooltip ({ children, overlayText, placement }) { return ( \ - @{item.user.name} + @{item.user.name} + {item.user.name === 'anon' + ? + : } {embellishUser} From 26762efcea062366330c5dbfe54b7e34d3ae7a80 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 17:35:11 -0500 Subject: [PATCH 28/54] add generic date pivot helper --- api/resolvers/user.js | 10 +++++----- components/snl.js | 2 +- lib/item.js | 2 +- lib/time.js | 13 +++++++++++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index c187de9eb..87a412bea 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -4,7 +4,7 @@ import { msatsToSats } from '../../lib/format' import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate' import { createMentions, getItem, SELECT, updateItem, filterClause } from './item' import serialize from './serial' -import { dayPivot } from '../../lib/time' +import { datePivot } from '../../lib/time' export function within (table, within) { let interval = ' AND "' + table + '".created_at >= $1 - INTERVAL ' @@ -54,13 +54,13 @@ export function viewWithin (table, within) { export function withinDate (within) { switch (within) { case 'day': - return dayPivot(new Date(), -1) + return datePivot(new Date(), { days: -1 }) case 'week': - return dayPivot(new Date(), -7) + return datePivot(new Date(), { days: -7 }) case 'month': - return dayPivot(new Date(), -30) + return datePivot(new Date(), { days: -30 }) case 'year': - return dayPivot(new Date(), -365) + return datePivot(new Date(), { days: -365 }) default: return new Date(0) } diff --git a/components/snl.js b/components/snl.js index 1df6f210d..55478eee1 100644 --- a/components/snl.js +++ b/components/snl.js @@ -12,7 +12,7 @@ export default function Snl ({ ignorePreference }) { useEffect(() => { const dismissed = window.localStorage.getItem('snl') - if (!ignorePreference && dismissed && dismissed > new Date(dismissed) < dayPivot(new Date(), -6)) { + if (!ignorePreference && dismissed && dismissed > new Date(dismissed) < datePivot(new Date(), { days: -6 })) { return } diff --git a/lib/item.js b/lib/item.js index 8ec61e249..538eaaf52 100644 --- a/lib/item.js +++ b/lib/item.js @@ -5,7 +5,7 @@ export const defaultCommentSort = (pinned, bio, createdAt) => { // pins sort by recent if (pinned) return 'recent' // old items (that aren't bios) sort by top - if (!bio && new Date(createdAt) < dayPivot(new Date(), -OLD_ITEM_DAYS)) return 'top' + if (!bio && new Date(createdAt) < datePivot(new Date(), { days: -OLD_ITEM_DAYS })) return 'top' // everything else sorts by hot return 'hot' } diff --git a/lib/time.js b/lib/time.js index 891482f85..ff0984bb1 100644 --- a/lib/time.js +++ b/lib/time.js @@ -20,8 +20,17 @@ export function timeSince (timeStamp) { return 'now' } -export function dayPivot (date, days) { - return new Date(date.getTime() + days * 24 * 60 * 60 * 1000) +export function datePivot (date, + { years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) { + return new Date( + date.getFullYear() + years, + date.getMonth() + months, + date.getDate() + days, + date.getHours() + hours, + date.getMinutes() + minutes, + date.getSeconds() + seconds, + date.getMilliseconds() + milliseconds + ) } export function timeLeft (timeStamp) { From 2fa34eccb6c1adcf82ef35696d016ca309b43a26 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 17:35:51 -0500 Subject: [PATCH 29/54] make anon user's invoices expire in 5 minutes --- api/resolvers/wallet.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index d9cc5fa18..7938d9c05 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -9,6 +9,7 @@ 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 { datePivot } from '../../lib/time' export async function getInvoice (parent, { id }, { me, models }) { const inv = await models.invoice.findUnique({ @@ -210,9 +211,9 @@ export default { await ssValidate(amountSchema, { amount }) const user = await models.user.findUnique({ where: { id: me ? me.id : ANON_USER_ID } }) + const pivot = me ? { hours: 3 } : { minutes: 5 } - // set expires at to 3 hours into future - const expiresAt = new Date(new Date().setHours(new Date().getHours() + 3)) + const expiresAt = datePivot(new Date(), pivot) const description = `Funding @${user.name} on stacker.news` try { const invoice = await createInvoice({ From 53a6c9489f58267a097b72dcc4fc526911ac33c7 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 17:40:50 -0500 Subject: [PATCH 30/54] fix forgotten find and replace --- components/snl.js | 2 +- lib/item.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/snl.js b/components/snl.js index 55478eee1..d382ddf68 100644 --- a/components/snl.js +++ b/components/snl.js @@ -2,7 +2,7 @@ import Alert from 'react-bootstrap/Alert' import YouTube from '../svgs/youtube-line.svg' import { useEffect, useState } from 'react' import { gql, useQuery } from '@apollo/client' -import { dayPivot } from '../lib/time' +import { datePivot } from '../lib/time' export default function Snl ({ ignorePreference }) { const [show, setShow] = useState() diff --git a/lib/item.js b/lib/item.js index 538eaaf52..2fe868e86 100644 --- a/lib/item.js +++ b/lib/item.js @@ -1,5 +1,5 @@ import { OLD_ITEM_DAYS } from './constants' -import { dayPivot } from './time' +import { datePivot } from './time' export const defaultCommentSort = (pinned, bio, createdAt) => { // pins sort by recent From e668b1f7f3dfd19a6d1648e306b2825227b998cd Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 17:56:30 -0500 Subject: [PATCH 31/54] use datePivot more places --- pages/api/capture/[[...path]].js | 3 ++- pages/api/lnurlp/[username]/pay.js | 3 ++- pages/api/lnwith.js | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pages/api/capture/[[...path]].js b/pages/api/capture/[[...path]].js index 5f71d93d1..342b179ca 100644 --- a/pages/api/capture/[[...path]].js +++ b/pages/api/capture/[[...path]].js @@ -1,6 +1,7 @@ import path from 'path' import AWS from 'aws-sdk' import { PassThrough } from 'stream' +import { datePivot } from '../../../lib/time' const { spawn } = require('child_process') const encodeS3URI = require('node-s3-url-encode') @@ -28,7 +29,7 @@ export default async function handler (req, res) { aws.headObject({ Bucket: bucketName, Key: s3PathPUT, - IfModifiedSince: new Date(new Date().getTime() - 15 * 60000) + IfModifiedSince: datePivot(new Date(), { minutes: -15 }) }).promise().then(() => { // this path is cached so return it res.writeHead(302, { Location: bucketUrl + s3PathGET }).end() diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 46dcae516..7e792ca2f 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -5,6 +5,7 @@ import { lnurlPayDescriptionHashForUser } from '../../../../lib/lnurl' import serialize from '../../../../api/resolvers/serial' import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' +import { datePivot } from '../../../../lib/time' export default async ({ query: { username, amount, nostr } }, res) => { const user = await models.user.findUnique({ where: { name: username } }) @@ -36,7 +37,7 @@ export default async ({ query: { username, amount, nostr } }, res) => { } // generate invoice - const expiresAt = new Date(new Date().setMinutes(new Date().getMinutes() + 1)) + const expiresAt = datePivot(new Date(), { minutes: 1 }) const invoice = await createInvoice({ description, description_hash: descriptionHash, diff --git a/pages/api/lnwith.js b/pages/api/lnwith.js index 5dc90cba3..afa8185f9 100644 --- a/pages/api/lnwith.js +++ b/pages/api/lnwith.js @@ -3,6 +3,7 @@ import models from '../../api/models' import getSSRApolloClient from '../../api/ssrApollo' import { CREATE_WITHDRAWL } from '../../fragments/wallet' +import { datePivot } from '../../lib/time' export default async ({ query }, res) => { if (!query.k1) { @@ -19,7 +20,7 @@ export default async ({ query }, res) => { where: { k1: query.k1, createdAt: { - gt: new Date(new Date().setHours(new Date().getHours() - 1)) + gt: datePivot(new Date(), { hours: -1 }) } } }) From ea9c405dfab52561b3097c1ad58df0ba012c1321 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 18:33:57 -0500 Subject: [PATCH 32/54] add sat amounts to invoices --- api/resolvers/wallet.js | 3 ++- api/typeDefs/wallet.js | 1 + components/invoice.js | 2 +- components/qr.js | 3 ++- fragments/wallet.js | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 7938d9c05..d987a85eb 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -296,7 +296,8 @@ export default { }, Invoice: { - satsReceived: i => msatsToSats(i.msatsReceived) + satsReceived: i => msatsToSats(i.msatsReceived), + satsRequested: i => msatsToSats(i.msatsRequested) }, Fact: { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 2bde43e04..3328bc902 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -23,6 +23,7 @@ export default gql` cancelled: Boolean! confirmedAt: Date satsReceived: Int + satsRequested: Int! nostr: JSONObject hmac: String } diff --git a/components/invoice.js b/components/invoice.js index 5d2e7ed6d..fe5aa52dc 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -41,7 +41,7 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) { return ( <> - +
{nostr ? { @@ -28,6 +28,7 @@ export default function Qr ({ asIs, value, webLn, statusVariant, status }) { className='h-auto mw-100' value={qrValue} renderAs='svg' size={300} /> + {description &&
{description}
}
diff --git a/fragments/wallet.js b/fragments/wallet.js index adc402211..0315ed4f5 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -7,6 +7,7 @@ export const INVOICE = gql` id hash bolt11 + satsRequested satsReceived cancelled confirmedAt From 0f74893c7d901669af4377db36bd99a3d2cb56bc Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 18:40:11 -0500 Subject: [PATCH 33/54] reduce anon invoice expiration to 3 minutes --- api/resolvers/wallet.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index d987a85eb..b5c405fed 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -211,7 +211,7 @@ export default { await ssValidate(amountSchema, { amount }) const user = await models.user.findUnique({ where: { id: me ? me.id : ANON_USER_ID } }) - const pivot = me ? { hours: 3 } : { minutes: 5 } + const pivot = me ? { hours: 3 } : { minutes: 3 } const expiresAt = datePivot(new Date(), pivot) const description = `Funding @${user.name} on stacker.news` From d92701c56f4eb07694f083338df60ba4bd67c4de Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 19:58:33 -0500 Subject: [PATCH 34/54] don't abbreviate --- components/invoice.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/invoice.js b/components/invoice.js index fe5aa52dc..2dfbadf32 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -41,7 +41,11 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) { return ( <> - +
{nostr ? Date: Fri, 11 Aug 2023 05:03:10 +0200 Subject: [PATCH 35/54] Fix [object Object] as error message Any errors thrown here are already objects of shape { message: string } --- components/invoice.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/invoice.js b/components/invoice.js index 2dfbadf32..364938be4 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -231,7 +231,7 @@ export const useInvoiceable = (fn, options = defaultOptions) => { }) return } - throw new Error({ message: error.toString() }) + throw error } } setFnArgs(args) From 41f46cf41ecbebc355309b4d2944070400219252 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 11 Aug 2023 05:11:41 +0200 Subject: [PATCH 36/54] Fix empty invoice creation attempts I stumbled across this while checking if anons can edit their items. I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error: Variable "$amount" of required type "Int!" was not provided. I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now. --- components/fee-button.js | 4 ++++ components/invoice.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/components/fee-button.js b/components/fee-button.js index 3f6939905..b2e4b505d 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -140,6 +140,10 @@ export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, const addImgLink = hasImgLink && !hadImgLink const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost) + useEffect(() => { + formik.setFieldValue('cost', cost) + }, [cost]) + const show = alwaysShow || !formik?.isSubmitting return (
diff --git a/components/invoice.js b/components/invoice.js index 364938be4..4a69918fd 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -215,7 +215,7 @@ export const useInvoiceable = (fn, options = defaultOptions) => { if (!me && options.requireSession) { throw new Error('you must be logged in') } - if (me && !options.forceInvoice) { + if (!amount || (me && !options.forceInvoice)) { try { return await fn(amount, ...args) } catch (error) { From 6ba1c3e8ab3a4793ec035f37d326a20c370ea117 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 10 Aug 2023 19:38:06 -0500 Subject: [PATCH 37/54] 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 cb3213a0a..7ab504ab1 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 b5c405fed..e48b3b5e2 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 user = await models.user.findUnique({ where: { id } }) - const expiresAt = datePivot(new Date(), pivot) + 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 d8d9d8e32..54df2169c 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 7e792ca2f..9571b0e44 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 000000000..9df4bd539 --- /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 From 5302263e2e9a2408dc41c01b2cd88b32f1241b43 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 12:25:30 -0500 Subject: [PATCH 38/54] anon tips should be denormalized --- .../migration.sql | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 prisma/migrations/20230811172050_denorm_anon_tips/migration.sql diff --git a/prisma/migrations/20230811172050_denorm_anon_tips/migration.sql b/prisma/migrations/20230811172050_denorm_anon_tips/migration.sql new file mode 100644 index 000000000..b9bcd6401 --- /dev/null +++ b/prisma/migrations/20230811172050_denorm_anon_tips/migration.sql @@ -0,0 +1,21 @@ +-- make excaption for anon users +CREATE OR REPLACE FUNCTION sats_after_tip(item_id INTEGER, user_id INTEGER, tip_msats BIGINT) RETURNS INTEGER AS $$ +DECLARE + item "Item"; +BEGIN + SELECT * FROM "Item" WHERE id = item_id INTO item; + IF user_id <> 27 AND item."userId" = user_id THEN + RETURN 0; + END IF; + + UPDATE "Item" + SET "msats" = "msats" + tip_msats + WHERE id = item.id; + + UPDATE "Item" + SET "commentMsats" = "commentMsats" + tip_msats + WHERE id <> item.id and path @> item.path; + + RETURN 1; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file From e4c2d113efcd0b7bdc44cdcf90a4c134dcbaee8a Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 12:41:02 -0500 Subject: [PATCH 39/54] remove redundant meTotalSats --- components/upvote.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/components/upvote.js b/components/upvote.js index 5347eb20d..da706ae85 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -208,13 +208,12 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt }, [me?.id, item?.fwdUserId, item?.mine, item?.deletedAt]) - const [meSats, meTotalSats, sats, overlayText, color] = useMemo(() => { - const meSats = (item?.meSats || 0) + pendingSats - const meTotalSats = meSats + (item?.meAnonSats || 0) + const [meSats, sats, overlayText, color] = useMemo(() => { + const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats // what should our next tip be? let sats = me?.tipDefault || 1 - if (me?.turboTipping && me) { + if (me?.turboTipping) { let raiseTip = sats while (meSats >= raiseTip) { raiseTip *= 10 @@ -223,7 +222,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } sats = raiseTip - meSats } - return [meSats, meTotalSats, sats, numWithUnits(sats, { abbreviate: false }), getColor(meSats)] + return [meSats, sats, numWithUnits(sats, { abbreviate: false }), getColor(meSats)] }, [item?.meSats, item?.meAnonSats, pendingSats, me?.tipDefault, me?.turboDefault]) return ( @@ -277,9 +276,9 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } `${styles.upvote} ${className || ''} ${disabled ? styles.noSelfTips : ''} - ${meTotalSats ? styles.voted : ''}` + ${meSats ? styles.voted : ''}` } - style={meTotalSats + style={meSats ? { fill: color, filter: `drop-shadow(0 0 6px ${color}90)` From 9c6ecf952606bd5875d6bcdcb94f2ed9d45c5ebf Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 12:43:18 -0500 Subject: [PATCH 40/54] correct overlay zap text for anon --- components/upvote.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/upvote.js b/components/upvote.js index da706ae85..13dcd6740 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -222,7 +222,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } sats = raiseTip - meSats } - return [meSats, sats, numWithUnits(sats, { abbreviate: false }), getColor(meSats)] + return [meSats, sats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it', getColor(meSats)] }, [item?.meSats, item?.meAnonSats, pendingSats, me?.tipDefault, me?.turboDefault]) return ( From 6e694139f43bb48501fedf12bf65928ed7257ab1 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 12:49:50 -0500 Subject: [PATCH 41/54] exclude anon from trust graph before algo runs --- worker/trust.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/worker/trust.js b/worker/trust.js index c7119fddf..7de7cd960 100644 --- a/worker/trust.js +++ b/worker/trust.js @@ -119,7 +119,7 @@ async function getGraph (models) { FROM "ItemAct" JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS') AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId" - JOIN users ON "ItemAct"."userId" = users.id + JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${ANON_USER_ID} GROUP BY user_id, name, item_id, user_at, against HAVING CASE WHEN "ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN} @@ -159,7 +159,6 @@ async function storeTrust (models, nodeTrust) { // convert nodeTrust into table literal string let values = '' for (const [id, trust] of Object.entries(nodeTrust)) { - if (id === ANON_USER_ID) continue if (values) values += ',' values += `(${id}, ${trust})` } From d406ccc2d833d75fc61cd13dd8c7b373230b2de7 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 12:59:09 -0500 Subject: [PATCH 42/54] remove balance limit on anon --- lib/constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/constants.js b/lib/constants.js index 54df2169c..e2caaa73a 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -23,7 +23,7 @@ module.exports = { INV_PENDING_LIMIT: 10, BALANCE_LIMIT_MSATS: 1000000000, // 1m sats ANON_INV_PENDING_LIMIT: 100, - ANON_BALANCE_LIMIT_MSATS: 100000000000, // 100m sats + ANON_BALANCE_LIMIT_MSATS: 0, // disable MAX_POLL_NUM_CHOICES: 10, MIN_POLL_NUM_CHOICES: 2, ITEM_FILTER_THRESHOLD: 1.2, From e995fd4929592714c60bff3648136f24a4aa0afe Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 13:32:01 -0500 Subject: [PATCH 43/54] give anon a bio and remove cowboy hat/top stackers; --- .../20230811180730_anon_bio/migration.sql | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 prisma/migrations/20230811180730_anon_bio/migration.sql diff --git a/prisma/migrations/20230811180730_anon_bio/migration.sql b/prisma/migrations/20230811180730_anon_bio/migration.sql new file mode 100644 index 000000000..d31065842 --- /dev/null +++ b/prisma/migrations/20230811180730_anon_bio/migration.sql @@ -0,0 +1,22 @@ +set transaction isolation level serializable; +-- hack ... prisma doesn't know about our other schemas (e.g. pgboss) +-- and this is only really a problem on their "shadow database" +-- so we catch the exception it throws and ignore it +CREATE OR REPLACE FUNCTION create_anon_bio() +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE +BEGIN + -- give anon a bio + PERFORM create_bio('@anon''s bio', 'account of stackers just passing through', 27); + -- hide anon from top users and dont give them a hat + UPDATE users set "hideFromTopUsers" = true, "hideCowboyHat" = true where id = 27; + return 0; +EXCEPTION WHEN sqlstate '42P01' THEN + return 0; +END; +$$; + +SELECT create_anon_bio(); +DROP FUNCTION IF EXISTS create_anon_bio(); From b2508b738aaf06fee055121207f645602618e3b5 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 14:12:18 -0500 Subject: [PATCH 44/54] make anon hat appear on profile --- components/fee-button.js | 7 ++++--- components/{cowboy-hat.js => hat.js} | 20 ++++++++++++++++++-- components/header.js | 4 ++-- components/item-info.js | 8 ++------ components/item-job.js | 4 ++-- components/user-header.js | 4 ++-- components/user-list.js | 4 ++-- fragments/users.js | 3 +++ 8 files changed, 35 insertions(+), 19 deletions(-) rename components/{cowboy-hat.js => hat.js} (60%) diff --git a/components/fee-button.js b/components/fee-button.js index b2e4b505d..21dfb9d80 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -55,13 +55,14 @@ function AnonInfo () { onClick={ (e) => showModal(onClose => -
Hey sneaky! You are posting without an account. -
    +
    You are posting without an account
    +
    1. You'll pay by invoice
    2. Your content will be content-joined (get it?!) under the @anon account
    3. Any sats your content earns will go toward rewards
    4. -
    5. We won't be able to notify about replies
    6. +
    7. We won't be able to notify you when you receive replies
    + btw if you don't need to be anonymouns posting is cheaper with an account
    ) } /> diff --git a/components/cowboy-hat.js b/components/hat.js similarity index 60% rename from components/cowboy-hat.js rename to components/hat.js index ac511f712..84aee1f70 100644 --- a/components/cowboy-hat.js +++ b/components/hat.js @@ -2,10 +2,26 @@ import Badge from 'react-bootstrap/Badge' import OverlayTrigger from 'react-bootstrap/OverlayTrigger' import Tooltip from 'react-bootstrap/Tooltip' import CowboyHatIcon from '../svgs/cowboy.svg' +import AnonIcon from '../svgs/spy-fill.svg' import { numWithUnits } from '../lib/format' +import { ANON_USER_ID } from '../lib/constants' -export default function CowboyHat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) { - if (user?.streak === null || user.hideCowboyHat) { +export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) { + if (!user) return null + if (Number(user.id) === ANON_USER_ID) { + return ( + + {badge + ? ( + + + ) + : } + + ) + } + + if (user.streak === null || user.hideCowboyHat) { return null } diff --git a/components/header.js b/components/header.js index a91bb34a8..a3f673a26 100644 --- a/components/header.js +++ b/components/header.js @@ -16,7 +16,6 @@ import { abbrNum } from '../lib/format' import NoteIcon from '../svgs/notification-4-fill.svg' import { useQuery } from '@apollo/client' import LightningIcon from '../svgs/bolt.svg' -import CowboyHat from './cowboy-hat' import { Select } from './form' import SearchIcon from '../svgs/search-line.svg' import BackArrow from '../svgs/arrow-left-line.svg' @@ -24,6 +23,7 @@ import { SSR, SUBS } from '../lib/constants' import { useLightning } from './lightning' import { HAS_NOTIFICATIONS } from '../fragments/notifications' import AnonIcon from '../svgs/spy-fill.svg' +import Hat from './hat' function WalletSummary ({ me }) { if (!me) return null @@ -84,7 +84,7 @@ function StackerCorner ({ dropNavKey }) { className={styles.dropdown} title={ e.preventDefault()}> - {`@${me.name}`} + {`@${me.name}`} } align='end' diff --git a/components/item-info.js b/components/item-info.js index 02f93f7b6..5ab8638ea 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -7,7 +7,6 @@ import Countdown from './countdown' import { abbrNum, numWithUnits } from '../lib/format' import { newComments, commentsViewedAt } from '../lib/new-comments' import { timeSince } from '../lib/time' -import CowboyHat, { HatTooltip } from './cowboy-hat' import { DeleteDropdownItem } from './delete' import styles from './item.module.css' import { useMe } from './me' @@ -16,7 +15,7 @@ import DontLikeThisDropdownItem from './dont-link-this' import BookmarkDropdownItem from './bookmark' import SubscribeDropdownItem from './subscribe' import { CopyLinkDropdownItem } from './share' -import AnonIcon from '../svgs/spy-fill.svg' +import Hat from './hat' export default function ItemInfo ({ item, pendingSats, full, commentsText = 'comments', @@ -85,10 +84,7 @@ export default function ItemInfo ({ \ - @{item.user.name} - {item.user.name === 'anon' - ? - : } + @{item.user.name} {embellishUser} diff --git a/components/item-job.js b/components/item-job.js index b3cbb5468..b8a49b757 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -9,7 +9,7 @@ import Link from 'next/link' import { timeSince } from '../lib/time' import EmailIcon from '../svgs/mail-open-line.svg' import Share from './share' -import CowboyHat from './cowboy-hat' +import Hat from './hat' export default function ItemJob ({ item, toc, rank, children }) { const isEmail = string().email().isValidSync(item.url) @@ -51,7 +51,7 @@ export default function ItemJob ({ item, toc, rank, children }) { \ - @{item.user.name} + @{item.user.name} diff --git a/components/user-header.js b/components/user-header.js index 8231a0e36..eb35b8cd9 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -14,10 +14,10 @@ import QRCode from 'qrcode.react' import LightningIcon from '../svgs/bolt.svg' import { encodeLNUrl } from '../lib/lnurl' import Avatar from './avatar' -import CowboyHat from './cowboy-hat' import { userSchema } from '../lib/validate' import { useShowModal } from './modal' import { numWithUnits } from '../lib/format' +import Hat from './hat' export default function UserHeader ({ user }) { const router = useRouter() @@ -149,7 +149,7 @@ function NymEdit ({ user, setEditting }) { function NymView ({ user, isMe, setEditting }) { return (
    -
    @{user.name}
    +
    @{user.name}
    {isMe && }
    diff --git a/components/user-list.js b/components/user-list.js index ebbbfcccf..a805a13ad 100644 --- a/components/user-list.js +++ b/components/user-list.js @@ -1,13 +1,13 @@ import Link from 'next/link' import Image from 'react-bootstrap/Image' import { abbrNum, numWithUnits } from '../lib/format' -import CowboyHat from './cowboy-hat' import styles from './item.module.css' import userStyles from './user-header.module.css' import { useEffect, useMemo, useState } from 'react' import { useQuery } from '@apollo/client' import MoreFooter from './more-footer' import { useData } from './use-data' +import Hat from './hat' // all of this nonsense is to show the stat we are sorting by first const Stacked = ({ user }) => ({abbrNum(user.stacked)} stacked) @@ -72,7 +72,7 @@ export default function UserList ({ ssrData, query, variables, destructureData }
    - @{user.name} + @{user.name}
    {statComps.map((Comp, i) => )} diff --git a/fragments/users.js b/fragments/users.js index c6960591d..b601db925 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -110,6 +110,7 @@ export const USER_SEARCH = gql` query searchUsers($q: String!, $limit: Int, $similarity: Float) { searchUsers(q: $q, limit: $limit, similarity: $similarity) { + id name streak hideCowboyHat @@ -139,6 +140,7 @@ export const TOP_USERS = gql` query TopUsers($cursor: String, $when: String, $by: String) { topUsers(cursor: $cursor, when: $when, by: $by) { users { + id name streak hideCowboyHat @@ -158,6 +160,7 @@ export const TOP_COWBOYS = gql` query TopCowboys($cursor: String) { topCowboys(cursor: $cursor) { users { + id name streak hideCowboyHat From 38fddcf283ec01bed2a21532646dac006c9dbb68 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 14:22:18 -0500 Subject: [PATCH 45/54] concat hash and hmac and call it a token --- components/invoice.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 4a69918fd..68e2ca9f8 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -69,13 +69,9 @@ const Contacts = ({ invoiceHash, invoiceHmac }) => { const body = 'Hi, I successfully paid for but the action did not work.' return (
    - Payment hash +
    payment token
    - -
    - Payment HMAC -
    - +
    Date: Fri, 11 Aug 2023 23:35:37 +0200 Subject: [PATCH 46/54] Fix localStorage cleared because error were swallowed --- components/bounty-form.js | 2 +- components/discussion-form.js | 2 +- components/form.js | 4 ++-- components/invoice.js | 7 +++++-- components/item-act.js | 2 +- components/job-form.js | 2 +- components/link-form.js | 2 +- components/poll-form.js | 2 +- components/reply.js | 2 +- 9 files changed, 14 insertions(+), 11 deletions(-) diff --git a/components/bounty-form.js b/components/bounty-form.js index 666869e72..ddc66b974 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -90,7 +90,7 @@ export function BountyForm ({ onSubmit={ handleSubmit || (async ({ boost, bounty, cost, ...values }) => { - await invoiceableUpsertBounty(cost, boost, bounty, values) + return invoiceableUpsertBounty(cost, boost, bounty, values) }) } storageKeyPrefix={item ? undefined : 'bounty'} diff --git a/components/discussion-form.js b/components/discussion-form.js index 769380a50..590786fdc 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -79,7 +79,7 @@ export function DiscussionForm ({ }} schema={schema} onSubmit={handleSubmit || (async ({ boost, cost, ...values }) => { - await invoiceableUpsertDiscussion(cost, boost, values) + return invoiceableUpsertDiscussion(cost, boost, values) })} storageKeyPrefix={item ? undefined : 'discussion'} > diff --git a/components/form.js b/components/form.js index 4688f1e29..48f12b84e 100644 --- a/components/form.js +++ b/components/form.js @@ -470,8 +470,8 @@ export function Form ({ initialTouched={validateImmediately && initial} validateOnBlur={false} onSubmit={async (values, ...args) => - onSubmit && onSubmit(values, ...args).then(() => { - if (!storageKeyPrefix) return + onSubmit && onSubmit(values, ...args).then((options) => { + if (!storageKeyPrefix || options?.keepLocalStorage) return Object.keys(values).forEach(v => { window.localStorage.removeItem(storageKeyPrefix + '-' + v) if (Array.isArray(values[v])) { diff --git a/components/invoice.js b/components/invoice.js index 68e2ca9f8..a4380f9ed 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -225,13 +225,16 @@ export const useInvoiceable = (fn, options = defaultOptions) => { /> ) }) - return + return { keepLocalStorage: true } } throw error } } setFnArgs(args) - return createInvoice({ variables: { amount } }) + await createInvoice({ variables: { amount } }) + // tell onSubmit handler that we want to keep local storage + // even though the submit handler was "successful" + return { keepLocalStorage: true } }, [fn, setFnArgs, createInvoice]) return actionFn diff --git a/components/item-act.js b/components/item-act.js index 6c5bc4ece..1f32bfc34 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -76,7 +76,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { }} schema={amountSchema} onSubmit={async ({ amount }) => { - await invoiceableAct(amount) + return invoiceableAct(amount) }} > { - await invoiceableUpsertJob(1000, maxBid, stop, start, values) + return invoiceableUpsertJob(1000, maxBid, stop, start, values) })} >
    diff --git a/components/link-form.js b/components/link-form.js index b9fa59127..f3d3ddb8a 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -119,7 +119,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) { }} schema={schema} onSubmit={async ({ boost, title, cost, ...values }) => { - await invoiceableUpsertLink(cost, boost, title, values) + return invoiceableUpsertLink(cost, boost, title, values) }} storageKeyPrefix={item ? undefined : 'link'} > diff --git a/components/poll-form.js b/components/poll-form.js index 91022c1c5..7d93cbc13 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -70,7 +70,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { }} schema={schema} onSubmit={async ({ boost, title, options, cost, ...values }) => { - await invoiceableUpsertPoll(cost, boost, title, options, values) + return invoiceableUpsertPoll(cost, boost, title, options, values) }} storageKeyPrefix={item ? undefined : 'poll'} > diff --git a/components/reply.js b/components/reply.js index e52d89717..62ba1e2ff 100644 --- a/components/reply.js +++ b/components/reply.js @@ -130,7 +130,7 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold }} schema={commentSchema} onSubmit={async ({ cost, ...values }, { resetForm }) => { - await invoiceableCreateComment(cost, values, parentId, resetForm) + return invoiceableCreateComment(cost, values, parentId, resetForm) }} storageKeyPrefix={'reply-' + parentId} > From 63dd5d4d09a0e0eb335a2b0178802a0fa34b6804 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 16:52:24 -0500 Subject: [PATCH 47/54] fix qr layout shift --- components/invoice.js | 2 +- components/qr.js | 7 ++++--- pages/invoices/[id].js | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index a4380f9ed..45440aa1f 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -118,7 +118,7 @@ const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => { return
    error
    } if (!data || loading) { - return + return } let errorStatus = 'Something went wrong trying to perform the action after payment.' diff --git a/components/qr.js b/components/qr.js index 441692190..5a7f866a9 100644 --- a/components/qr.js +++ b/components/qr.js @@ -37,11 +37,12 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st ) } -export function QrSkeleton ({ status }) { +export function QrSkeleton ({ status, description }) { return ( <> -
    -
    +
    + {description &&
    .
    } +
    diff --git a/pages/invoices/[id].js b/pages/invoices/[id].js index edd5c3d24..37457efd1 100644 --- a/pages/invoices/[id].js +++ b/pages/invoices/[id].js @@ -19,7 +19,7 @@ export default function FullInvoice () { return ( {error &&
    {error.toString()}
    } - {data ? : } + {data ? : }
    ) } From 39db6e096d8feb96d7c60d2562d0b99f506250df Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 17:02:53 -0500 Subject: [PATCH 48/54] restyle fund error modal --- components/fund-error.js | 6 +++--- pages/wallet.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/fund-error.js b/components/fund-error.js index 9516f4fa2..422860c47 100644 --- a/components/fund-error.js +++ b/components/fund-error.js @@ -6,12 +6,12 @@ export default function FundError ({ onClose, amount, onPayment }) { const createInvoice = useInvoiceable(onPayment, { forceInvoice: true }) return ( <> -

    you need more sats

    -
    +

    you need more sats

    +
    - or + or
    diff --git a/pages/wallet.js b/pages/wallet.js index 33b817728..0e2c1539c 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -92,7 +92,7 @@ export function FundForm () { }, []) if (called && !error) { - return + return } return ( From 9e4f9aa55886ff470c0936b85d2238cb74d3ce3a Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 12 Aug 2023 00:33:51 +0200 Subject: [PATCH 49/54] Catch invoice errors in fund error modal --- components/fund-error.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/fund-error.js b/components/fund-error.js index 422860c47..cf0e99c68 100644 --- a/components/fund-error.js +++ b/components/fund-error.js @@ -1,18 +1,22 @@ import Link from 'next/link' import Button from 'react-bootstrap/Button' import { useInvoiceable } from './invoice' +import { Alert } from 'react-bootstrap' +import { useState } from 'react' export default function FundError ({ onClose, amount, onPayment }) { + const [error, setError] = useState(null) const createInvoice = useInvoiceable(onPayment, { forceInvoice: true }) return ( <> + {error && setError(undefined)} dismissible>{error}}

    you need more sats

    or - +
    ) From a5eb7b54436156531ab8ca65f83f0687918b3755 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 17:53:21 -0500 Subject: [PATCH 50/54] invoice check backoff --- components/invoice.js | 2 +- lib/time.js | 10 ++++++---- worker/wallet.js | 11 ++++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 45440aa1f..bfd9095c6 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -165,7 +165,7 @@ export const useInvoiceable = (fn, options = defaultOptions) => { const onConfirmation = useCallback( (onClose, hmac) => { return async ({ id, satsReceived, hash }) => { - await sleep(2000) + await sleep(500) const repeat = () => fn(satsReceived, ...fnArgs, hash, hmac) .then(onClose) diff --git a/lib/time.js b/lib/time.js index ff0984bb1..3573d4b77 100644 --- a/lib/time.js +++ b/lib/time.js @@ -1,4 +1,4 @@ -export function timeSince (timeStamp) { +function timeSince (timeStamp) { const now = new Date() const secondsPast = (now.getTime() - timeStamp) / 1000 if (secondsPast < 60) { @@ -20,7 +20,7 @@ export function timeSince (timeStamp) { return 'now' } -export function datePivot (date, +function datePivot (date, { years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) { return new Date( date.getFullYear() + years, @@ -33,7 +33,7 @@ export function datePivot (date, ) } -export function timeLeft (timeStamp) { +function timeLeft (timeStamp) { const now = new Date() const secondsPast = (timeStamp - now.getTime()) / 1000 @@ -55,4 +55,6 @@ export function timeLeft (timeStamp) { } } -export const sleep = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms)) +const sleep = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms)) + +module.exports = { timeSince, datePivot, timeLeft, sleep } diff --git a/worker/wallet.js b/worker/wallet.js index bcd7c3dcb..745760344 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -1,8 +1,10 @@ const serialize = require('../api/resolvers/serial') const { getInvoice, getPayment } = require('ln-service') +const { datePivot } = require('../lib/time') const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true } +// TODO this should all be done via websockets function checkInvoice ({ boss, models, lnd }) { return async function ({ data: { hash } }) { let inv @@ -32,8 +34,10 @@ function checkInvoice ({ boss, models, lnd }) { } })) } else if (new Date(inv.expires_at) > new Date()) { - // not expired, recheck in 5 seconds - await boss.send('checkInvoice', { hash }, walletOptions) + // not expired, recheck in 5 seconds if the invoice is younger than 5 minutes + // otherwise recheck in 60 seconds + const startAfter = new Date(inv.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60 + await boss.send('checkInvoice', { hash }, { ...walletOptions, startAfter }) } } } @@ -76,7 +80,8 @@ function checkWithdrawal ({ boss, models, lnd }) { SELECT reverse_withdrawl(${id}::INTEGER, ${status}::"WithdrawlStatus")`) } else { // we need to requeue to check again in 5 seconds - await boss.send('checkWithdrawal', { id, hash }, walletOptions) + const startAfter = new Date(wdrwl.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60 + await boss.send('checkWithdrawal', { id, hash }, { ...walletOptions, startAfter }) } } } From abb9ca55249c54ab6568f1f980325c05f2cad625 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 17:53:56 -0500 Subject: [PATCH 51/54] anon info typo --- components/fee-button.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/fee-button.js b/components/fee-button.js index 21dfb9d80..b8e4ecf2c 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -62,7 +62,7 @@ function AnonInfo () {
  1. Any sats your content earns will go toward rewards
  2. We won't be able to notify you when you receive replies
- btw if you don't need to be anonymouns posting is cheaper with an account + btw if you don't need to be anonymous posting is cheaper with an account
) } /> From 86239a235d2abd49d3fdf89dd054bb47b53d7ba7 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 18:10:46 -0500 Subject: [PATCH 52/54] make invoice expiration times have saner defaults --- api/resolvers/wallet.js | 4 ++-- api/typeDefs/wallet.js | 2 +- components/invoice.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index e48b3b5e2..e3b82d038 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -207,10 +207,10 @@ export default { }, Mutation: { - createInvoice: async (parent, { amount }, { me, models, lnd }) => { + createInvoice: async (parent, { amount, expireSecs = 3600 }, { me, models, lnd }) => { await ssValidate(amountSchema, { amount }) - let expirePivot = { hours: 3 } + let expirePivot = { seconds: expireSecs } let invLimit = INV_PENDING_LIMIT let balanceLimit = BALANCE_LIMIT_MSATS let id = me?.id diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 3328bc902..6eefe9997 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -9,7 +9,7 @@ export default gql` } extend type Mutation { - createInvoice(amount: Int!): Invoice! + createInvoice(amount: Int!, expireSecs: Int): Invoice! createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!): Withdrawl! } diff --git a/components/invoice.js b/components/invoice.js index bfd9095c6..4dbcec1ab 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -151,7 +151,7 @@ export const useInvoiceable = (fn, options = defaultOptions) => { const me = useMe() const [createInvoice, { data }] = useMutation(gql` mutation createInvoice($amount: Int!) { - createInvoice(amount: $amount) { + createInvoice(amount: $amount, expireSecs: 1800) { id hash hmac From 705e21a72c425236584f3b44f920559466f2e03e Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 18:19:35 -0500 Subject: [PATCH 53/54] add comma to anon info --- components/fee-button.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/fee-button.js b/components/fee-button.js index b8e4ecf2c..3ff672d9d 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -62,7 +62,7 @@ function AnonInfo () {
  • Any sats your content earns will go toward rewards
  • We won't be able to notify you when you receive replies
  • - btw if you don't need to be anonymous posting is cheaper with an account + btw if you don't need to be anonymous, posting is cheaper with an account
    ) } /> From 5b821906cf29491796095bd45d5a1a7baba5124d Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 18:43:45 -0500 Subject: [PATCH 54/54] use builtin copy input label --- api/resolvers/item.js | 12 ++++++++---- components/invoice.js | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 7ab504ab1..d92478108 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -672,7 +672,8 @@ 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, '${spamInterval}') 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 @@ -718,7 +719,8 @@ export default { }, createComment: async (parent, data, { me, models }) => { await ssValidate(commentSchema, data) - const item = await createItem(parent, data, { me, models, invoiceHash: data.invoiceHash, invoiceHmac: data.invoiceHmac }) + const item = await createItem(parent, data, + { me, models, invoiceHash: data.invoiceHash, invoiceHmac: data.invoiceHmac }) // fetch user to get up-to-date name const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) @@ -787,7 +789,8 @@ export default { const [{ item_act: vote }] = await serialize(models, ...calls) const updatedItem = await models.item.findUnique({ where: { id: Number(id) } }) - const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${numWithUnits(msatsToSats(updatedItem.msats))}${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}` + const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${ + numWithUnits(msatsToSats(updatedItem.msats))}${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}` sendUserNotification(updatedItem.userId, { title, body: updatedItem.title ? updatedItem.title : updatedItem.text, @@ -815,7 +818,8 @@ export default { throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } }) } - await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`) + await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, + ${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`) return true } diff --git a/components/invoice.js b/components/invoice.js index 4dbcec1ab..8d2a5606c 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -69,9 +69,11 @@ const Contacts = ({ invoiceHash, invoiceHmac }) => { const body = 'Hi, I successfully paid for but the action did not work.' return (
    -
    payment token
    - + payment token save this} + type='text' placeholder={invoiceHash + '|' + invoiceHmac} readOnly noForm + />