From 5415c6b0f6133121b32057d4c1347b3e56569b72 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 13 Jul 2023 05:08:32 +0200 Subject: [PATCH 01/56] 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 c0aa5bfe..cb20b05a 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 ec7d6bcb..5edd2c2e 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 b4878aa6..08d716bd 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 60b5e09f..7962d3c8 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 5e398714..74fe7de4 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 800b8691..77fe0a01 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 c5281b1d..0fe7e73a 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 b8db60bb..c57cb5fb 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 0d9c1254..c93e2743 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 00000000..46b0f5ba --- /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 25ba82d5..382ce427 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 3af84eca..f0bcc8df 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 7ff3cec9..54405bc0 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 00000000..49cbe800 --- /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/56] 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 cb20b05a..94b99463 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 7962d3c8..86e5d99e 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 3243fd03..3e43418e 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 6b47f25b..1eae2506 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 07fd7532..6ed6a115 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 3aef521d..33c7bc47 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 9493e109..3cc6c706 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 6723a771..73877bbd 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 1eda2eb7..58745653 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 46b0f5ba..f47a5a66 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 f0bcc8df..5c42a693 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 00000000..28992e63 --- /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/56] 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 94b99463..ff356d6c 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 86e5d99e..0ca7f821 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 dc894e9b..698e3695 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 3e43418e..600b50bd 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 77fe0a01..e5323fdf 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 33c7bc47..03465f16 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 3cc6c706..d719def0 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 58745653..6011fef0 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 0fe7e73a..c19572fc 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 62f55d1c..db0cae7f 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 f47a5a66..b7705869 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/56] 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 600b50bd..14271f73 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 1eae2506..f6daeac1 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 e25cddfe..01297e8d 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 e5323fdf..84884bd3 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 d719def0..5cf26156 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 c19572fc..a04e69a4 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 b7705869..16d95543 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/56] 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 16d95543..663a1c37 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/56] 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 183b762e..dc54d411 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 663a1c37..559854a1 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/56] 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 38b372af..c7119fdd 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/56] 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 559854a1..06807c1d 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 b1f2e4fe..891482f8 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/56] 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 06807c1d..00b62d16 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/56] 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 84884bd3..e5323fdf 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/56] 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 a04e69a4..95ec48e5 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/56] 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 6011fef0..41119a41 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/56] 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 8be19d05..c1c2fb98 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 b0884184..d91c0323 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 28edcd0c..77f463a7 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/56] 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 95ec48e5..d19a839e 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/56] 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 5c42a693..467c492d 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'] +const SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs'] +const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs') -export const ITEM_TYPES = context => { - if (context === 'jobs') { - return ['posts', 'comments', 'all', 'freebies'] - } - - 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/56] 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 23b5be99..573d5e8e 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 ffc7a4f3..3e3f09a9 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/56] 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 2b3a4fd0..edd5c3d2 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/56] 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 c1c2fb98..16e6ffdd 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 d91c0323..b3e04ca5 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/56] 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 323c7460..8467f811 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 16e6ffdd..96dd86d7 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 14271f73..19be6057 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 01297e8d..9516f4fa 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 92664108..5f0526fb 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 e5323fdf..860ae017 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 fa30ed83..65eae961 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 5cf26156..402fb9ab 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 41119a41..e748f8b1 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 a24f477b..9965a98a 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 77f463a7..00000000 --- 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/56] 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 3e3f09a9..d8d9d8e3 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/56] 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 5f0526fb..d2f4bca5 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/56] 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 cab864f6..38da8e7f 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 8467f811..57673785 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 f1b57003..d9cc5fa1 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 0ca7f821..5852d529 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 3cc3a9f6..2bde43e0 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 96dd86d7..666869e7 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 19be6057..769380a5 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 d2f4bca5..678db5e9 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 860ae017..6c5bc4ec 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 21d9990c..8a8027f1 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 65eae961..b9fa5912 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 402fb9ab..91022c1c 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 e748f8b1..e52d8971 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 9965a98a..5347eb20 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/56] 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 05ed3f20..3f693990 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 c2d40c54..09fdc2dc 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 7525f656..a91bb34a 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 a5340c94..6abd5e10 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 bd127208..271599ff 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 678db5e9..06d5c670 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 00000000..eb84073d --- /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/56] 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 57673785..cb3213a0 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/56] 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 06d5c670..5d2e7ed6 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/56] 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 dc54d411..5cea3ca8 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 9650e5f0..00210ddf 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/56] 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 fda60d27..ac511f71 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/56] 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 c187de9e..87a412be 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 1df6f210..55478eee 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 8ec61e24..538eaaf5 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 891482f8..ff0984bb 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/56] 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 d9cc5fa1..7938d9c0 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/56] 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 55478eee..d382ddf6 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 538eaaf5..2fe868e8 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/56] 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 5f71d93d..342b179c 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 46dcae51..7e792ca2 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 5dc90cba..afa8185f 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/56] 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 7938d9c0..d987a85e 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 2bde43e0..3328bc90 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 5d2e7ed6..fe5aa52d 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 adc40221..0315ed4f 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/56] 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 d987a85e..b5c405fe 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/56] 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 fe5aa52d..2dfbadf3 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/56] 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 2dfbadf3..364938be 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/56] 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 3f693990..b2e4b505 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 364938be..4a69918f 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/56] anon func mods, e.g. inv limits --- api/resolvers/item.js | 10 +- api/resolvers/wallet.js | 21 ++- lib/constants.js | 5 + pages/api/lnurlp/[username]/pay.js | 6 +- .../migration.sql | 144 ++++++++++++++++++ 5 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20230810234326_anon_func_exemptions/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index cb3213a0..7ab504ab 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -8,7 +8,7 @@ import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, - ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE + ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE, ANON_ITEM_SPAM_INTERVAL } from '../../lib/constants' import { msatsToSats, numWithUnits } from '../../lib/format' import { parse } from 'tldts' @@ -627,10 +627,12 @@ export default { upsertPoll: async (parent, { id, ...data }, { me, models }) => { const { sub, forward, boost, title, text, options, invoiceHash, invoiceHmac } = data let author = me + let spamInterval = ITEM_SPAM_INTERVAL const trx = [] if (!me && invoiceHash) { const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, ANON_POST_FEE) author = invoice.user + spamInterval = ANON_ITEM_SPAM_INTERVAL trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } @@ -670,7 +672,7 @@ export default { return item } else { const [query] = await serialize(models, - models.$queryRawUnsafe(`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`, + models.$queryRawUnsafe(`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${spamInterval}') AS "Item"`, sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, Number(fwdUser?.id)), ...trx) const item = trx.length > 0 ? query[0] : query @@ -1107,10 +1109,12 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash, invoiceHmac }) => { let author = me + let spamInterval = ITEM_SPAM_INTERVAL const trx = [] if (!me && invoiceHash) { const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) author = invoice.user + spamInterval = ANON_ITEM_SPAM_INTERVAL trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } @@ -1140,7 +1144,7 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount const [query] = await serialize( models, models.$queryRawUnsafe( - `${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`, + `${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${spamInterval}') AS "Item"`, parentId ? null : sub || 'bitcoin', title, url, diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index b5c405fe..e48b3b5e 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -8,7 +8,7 @@ import { SELECT } from './item' import { lnurlPayDescriptionHash } from '../../lib/lnurl' import { msatsToSats, msatsToSatsDecimal } from '../../lib/format' import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../lib/validate' -import { ANON_USER_ID } from '../../lib/constants' +import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT } from '../../lib/constants' import { datePivot } from '../../lib/time' export async function getInvoice (parent, { id }, { me, models }) { @@ -210,10 +210,20 @@ export default { createInvoice: async (parent, { amount }, { me, models, lnd }) => { await ssValidate(amountSchema, { amount }) - const user = await models.user.findUnique({ where: { id: me ? me.id : ANON_USER_ID } }) - const pivot = me ? { hours: 3 } : { minutes: 3 } + let expirePivot = { hours: 3 } + let invLimit = INV_PENDING_LIMIT + let balanceLimit = BALANCE_LIMIT_MSATS + let id = me?.id + if (!me) { + expirePivot = { minutes: 3 } + invLimit = ANON_INV_PENDING_LIMIT + balanceLimit = ANON_BALANCE_LIMIT_MSATS + id = ANON_USER_ID + } - const expiresAt = datePivot(new Date(), pivot) + const user = await models.user.findUnique({ where: { id } }) + + const expiresAt = datePivot(new Date(), expirePivot) const description = `Funding @${user.name} on stacker.news` try { const invoice = await createInvoice({ @@ -225,7 +235,8 @@ export default { const [inv] = await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, - ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description})`) + ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, + ${invLimit}::INTEGER, ${balanceLimit})`) // the HMAC is only returned during invoice creation // this makes sure that only the person who created this invoice diff --git a/lib/constants.js b/lib/constants.js index d8d9d8e3..54df2169 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -19,6 +19,11 @@ module.exports = { MAX_TITLE_LENGTH: 80, MAX_POLL_CHOICE_LENGTH: 30, ITEM_SPAM_INTERVAL: '10m', + ANON_ITEM_SPAM_INTERVAL: '0', + INV_PENDING_LIMIT: 10, + BALANCE_LIMIT_MSATS: 1000000000, // 1m sats + ANON_INV_PENDING_LIMIT: 100, + ANON_BALANCE_LIMIT_MSATS: 100000000000, // 100m sats MAX_POLL_NUM_CHOICES: 10, MIN_POLL_NUM_CHOICES: 2, ITEM_FILTER_THRESHOLD: 1.2, diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 7e792ca2..9571b0e4 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -6,6 +6,7 @@ import serialize from '../../../../api/resolvers/serial' import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' import { datePivot } from '../../../../lib/time' +import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT } from '../../../../lib/constants' export default async ({ query: { username, amount, nostr } }, res) => { const user = await models.user.findUnique({ where: { name: username } }) @@ -13,7 +14,7 @@ export default async ({ query: { username, amount, nostr } }, res) => { return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` }) } try { - // if nostr, decode, validate sig, check tags, set description hash + // if nostr, decode, validate sig, check tags, set description hash let description, descriptionHash, noteStr if (nostr) { noteStr = decodeURIComponent(nostr) @@ -48,7 +49,8 @@ export default async ({ query: { username, amount, nostr } }, res) => { await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, - ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description})`) + ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description}, + ${INV_PENDING_LIMIT}::INTEGER, ${BALANCE_LIMIT_MSATS})`) return res.status(200).json({ pr: invoice.request, diff --git a/prisma/migrations/20230810234326_anon_func_exemptions/migration.sql b/prisma/migrations/20230810234326_anon_func_exemptions/migration.sql new file mode 100644 index 00000000..9df4bd53 --- /dev/null +++ b/prisma/migrations/20230810234326_anon_func_exemptions/migration.sql @@ -0,0 +1,144 @@ +DROP FUNCTION IF EXISTS create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, msats_req BIGINT, user_id INTEGER, idesc TEXT); +-- make invoice limit and balance limit configurable +CREATE OR REPLACE FUNCTION create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, + msats_req BIGINT, user_id INTEGER, idesc TEXT, inv_limit INTEGER, balance_limit_msats BIGINT) +RETURNS "Invoice" +LANGUAGE plpgsql +AS $$ +DECLARE + invoice "Invoice"; + inv_limit_reached BOOLEAN; + balance_limit_reached BOOLEAN; + inv_pending_msats BIGINT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + -- prevent too many pending invoices + SELECT inv_limit > 0 AND count(*) >= inv_limit, sum("msatsRequested") INTO inv_limit_reached, inv_pending_msats + FROM "Invoice" + WHERE "userId" = user_id AND "expiresAt" > now_utc() AND "confirmedAt" IS NULL AND cancelled = false; + + IF inv_limit_reached THEN + RAISE EXCEPTION 'SN_INV_PENDING_LIMIT'; + END IF; + + -- prevent pending invoices + msats from exceeding the limit + SELECT balance_limit_msats > 0 AND inv_pending_msats+msats_req+msats > balance_limit_msats INTO balance_limit_reached + FROM users + WHERE id = user_id; + + IF balance_limit_reached THEN + RAISE EXCEPTION 'SN_INV_EXCEED_BALANCE'; + END IF; + + -- we good, proceed frens + INSERT INTO "Invoice" (hash, bolt11, "expiresAt", "msatsRequested", "userId", created_at, updated_at, "desc") + VALUES (hash, bolt11, expires_at, msats_req, user_id, now_utc(), now_utc(), idesc) RETURNING * INTO invoice; + + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('checkInvoice', jsonb_build_object('hash', hash), 21, true, now() + interval '10 seconds'); + + RETURN invoice; +END; +$$; + +-- don't presume outlaw status for anon posters +CREATE OR REPLACE FUNCTION create_item( + sub TEXT, title TEXT, url TEXT, text TEXT, boost INTEGER, bounty INTEGER, + parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER, + spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats BIGINT; + cost_msats BIGINT; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT msats INTO user_msats FROM users WHERE id = user_id; + + cost_msats := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)); + -- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0 + freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (boost = 0); + + IF NOT freebie AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + INTO med_votes FROM "Item" WHERE "userId" = user_id; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + -- addendum: if they're an anon poster, always start at 0 + IF med_votes >= 0 OR user_id = 27 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + INSERT INTO "Item" + ("subName", title, url, text, bounty, "userId", "parentId", "fwdUserId", + freebie, "weightedDownVotes", created_at, updated_at) + VALUES + (sub, title, url, text, bounty, user_id, parent_id, fwd_user_id, + freebie, med_votes, now_utc(), now_utc()) RETURNING * INTO item; + + IF NOT freebie THEN + UPDATE users SET msats = msats - cost_msats WHERE id = user_id; + + INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at) + VALUES (cost_msats, item.id, user_id, 'FEE', now_utc(), now_utc()); + END IF; + + IF boost > 0 THEN + PERFORM item_act(item.id, user_id, 'BOOST', boost); + END IF; + + RETURN item; +END; +$$; + +-- keep item_spam unaware of anon user +CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + repeats INTEGER; + self_replies INTEGER; +BEGIN + -- no fee escalation + IF within = interval '0' THEN + RETURN 0; + END IF; + + SELECT count(*) INTO repeats + FROM "Item" + WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id) + AND "userId" = user_id + AND created_at > now_utc() - within; + + IF parent_id IS NULL THEN + RETURN repeats; + END IF; + + WITH RECURSIVE base AS ( + SELECT "Item".id, "Item"."parentId", "Item"."userId" + FROM "Item" + WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within + UNION ALL + SELECT "Item".id, "Item"."parentId", "Item"."userId" + FROM base p + JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within) + SELECT count(*) INTO self_replies FROM base; + + RETURN repeats + self_replies; +END; +$$; \ No newline at end of file From 5302263e2e9a2408dc41c01b2cd88b32f1241b43 Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 11 Aug 2023 12:25:30 -0500 Subject: [PATCH 38/56] 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 00000000..b9bcd640 --- /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/56] 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 5347eb20..da706ae8 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/56] 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 da706ae8..13dcd674 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/56] 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 c7119fdd..7de7cd96 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/56] 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 54df2169..e2caaa73 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/56] 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 00000000..d3106584 --- /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/56] 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 b2e4b505..21dfb9d8 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 ac511f71..84aee1f7 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 a91bb34a..a3f673a2 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 02f93f7b..5ab8638e 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 b3cbb546..b8a49b75 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 8231a0e3..eb35b8cd 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 ebbbfccc..a805a13a 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 c6960591..b601db92 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/56] 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 4a69918f..68e2ca9f 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/56] 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 666869e7..ddc66b97 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 769380a5..590786fd 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 4688f1e2..48f12b84 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 68e2ca9f..a4380f9e 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 6c5bc4ec..1f32bfc3 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 b9fa5912..f3d3ddb8 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 91022c1c..7d93cbc1 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 e52d8971..62ba1e2f 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/56] 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 a4380f9e..45440aa1 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 44169219..5a7f866a 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 edd5c3d2..37457efd 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/56] 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 9516f4fa..422860c4 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 33b81772..0e2c1539 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/56] 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 422860c4..cf0e99c6 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/56] 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 45440aa1..bfd9095c 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 ff0984bb..3573d4b7 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 bcd7c3dc..74576034 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/56] 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 21dfb9d8..b8e4ecf2 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/56] 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 e48b3b5e..e3b82d03 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 3328bc90..6eefe999 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 bfd9095c..4dbcec1a 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/56] 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 b8e4ecf2..3ff672d9 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/56] 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 7ab504ab..d9247810 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 4dbcec1a..8d2a5606 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 + />
    Date: Sat, 12 Aug 2023 02:40:37 +0200 Subject: [PATCH 55/56] Fix cost not set after form reset --- components/fee-button.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/fee-button.js b/components/fee-button.js index 3ff672d9..96f28473 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -82,8 +82,8 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost) useEffect(() => { - formik.setFieldValue('cost', cost) - }, [cost]) + formik?.setFieldValue('cost', cost) + }, [formik?.getFieldProps('cost').value, cost]) const show = alwaysShow || !formik?.isSubmitting return ( @@ -142,8 +142,8 @@ export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost) useEffect(() => { - formik.setFieldValue('cost', cost) - }, [cost]) + formik?.setFieldValue('cost', cost) + }, [formik?.getFieldProps('cost').value, cost]) const show = alwaysShow || !formik?.isSubmitting return ( From f0bc1baed2f4328fedaf357f2f8252bc3f430503 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 12 Aug 2023 03:18:56 +0200 Subject: [PATCH 56/56] Save payment tokens in localStorage --- components/invoice.js | 6 ++++++ components/payment-tokens.js | 40 ++++++++++++++++++++++++++++++++++++ pages/_app.js | 10 ++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 components/payment-tokens.js diff --git a/components/invoice.js b/components/invoice.js index 8d2a5606..172f9630 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -12,6 +12,7 @@ import { useMe } from './me' import { useShowModal } from './modal' import { sleep } from '../lib/time' import FundError, { isInsufficientFundsError } from './fund-error' +import { usePaymentTokens } from './payment-tokens' export function Invoice ({ invoice, onConfirmation, successVerb }) { let variant = 'default' @@ -161,15 +162,20 @@ export const useInvoiceable = (fn, options = defaultOptions) => { }`) const showModal = useShowModal() const [fnArgs, setFnArgs] = useState() + const { addPaymentToken, removePaymentToken } = usePaymentTokens() // fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice let errorCount = 0 const onConfirmation = useCallback( (onClose, hmac) => { return async ({ id, satsReceived, hash }) => { + addPaymentToken(hash, hmac, satsReceived) await sleep(500) const repeat = () => fn(satsReceived, ...fnArgs, hash, hmac) + .then(() => { + removePaymentToken(hash, hmac) + }) .then(onClose) .catch((error) => { console.error(error) diff --git a/components/payment-tokens.js b/components/payment-tokens.js new file mode 100644 index 00000000..3ac8a63d --- /dev/null +++ b/components/payment-tokens.js @@ -0,0 +1,40 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react' + +export const PaymentTokenContext = React.createContext() + +const fetchTokensFromLocalStorage = () => { + const tokens = JSON.parse(window.localStorage.getItem('payment-tokens') || '[]') + return tokens +} + +export function PaymentTokenProvider ({ children }) { + const [tokens, setTokens] = useState([]) + + useEffect(() => { + setTokens(fetchTokensFromLocalStorage()) + }, []) + + const addPaymentToken = useCallback((hash, hmac, amount) => { + const token = hash + '|' + hmac + const newTokens = [...tokens, { token, amount }] + window.localStorage.setItem('payment-tokens', JSON.stringify(newTokens)) + setTokens(newTokens) + }, [tokens]) + + const removePaymentToken = useCallback((hash, hmac) => { + const token = hash + '|' + hmac + const newTokens = tokens.filter(({ token: t }) => t !== token) + window.localStorage.setItem('payment-tokens', JSON.stringify(newTokens)) + setTokens(newTokens) + }, [tokens]) + + return ( + + {children} + + ) +} + +export function usePaymentTokens () { + return useContext(PaymentTokenContext) +} diff --git a/pages/_app.js b/pages/_app.js index af7b4fcd..c7f2f186 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -14,6 +14,7 @@ import { ServiceWorkerProvider } from '../components/serviceworker' import { SSR } from '../lib/constants' import NProgress from 'nprogress' import 'nprogress/nprogress.css' +import { PaymentTokenProvider } from '../components/payment-tokens' NProgress.configure({ showSpinner: false @@ -89,11 +90,14 @@ function MyApp ({ Component, pageProps: { ...props } }) { - - - + + + + + +