From 73ad93f2bb821493ef3eea292df7bc22c5a34579 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 26 Dec 2023 20:27:52 -0600 Subject: [PATCH] idempotent zaps --- api/resolvers/item.js | 46 +++++++ api/typeDefs/item.js | 1 + components/comment.js | 4 +- components/dont-link-this.js | 4 +- components/item-act.js | 115 +++++++++++++++- components/item-info.js | 8 +- components/item.js | 7 +- components/items.js | 1 - components/upvote.js | 128 ++++++------------ .../migration.sql | 88 ++++++++++++ 10 files changed, 301 insertions(+), 101 deletions(-) create mode 100644 prisma/migrations/20231226230356_item_act_negative/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index ecc010a6..2c3b3b97 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -786,6 +786,52 @@ export default { notifyZapped({ models, id }) + return { + id, + sats, + act, + path: item.path + } + }, + idempotentAct: async (parent, { id, sats, act = 'TIP', hash, hmac }, { me, models, lnd, headers }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + } + + await ssValidate(actSchema, { sats, act }) + await assertGofacYourself({ models, headers }) + + const [item] = await models.$queryRawUnsafe(` + ${SELECT} + FROM "Item" + WHERE id = $1`, Number(id)) + + if (Number(item.userId) === Number(me.id)) { + throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } }) + } + + // Disallow tips if me is one of the forward user recipients + if (act === 'TIP') { + const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } }) + if (existingForwards.some(fwd => Number(fwd.userId) === Number(me.id))) { + throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } }) + } + } + + await serializeInvoicable( + models.$queryRaw` + SELECT + item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, ${act}::"ItemActType", + (SELECT ${Number(sats)}::INTEGER - COALESCE(sum(msats) / 1000, 0) + FROM "ItemAct" + WHERE act IN ('TIP', 'FEE') + AND "itemId" = ${Number(id)}::INTEGER + AND "userId" = ${me.id}::INTEGER)::INTEGER)`, + { me, models, lnd, hash, hmac, enforceFee: sats } + ) + + notifyZapped({ models, id }) + return { id, sats, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index f8c612b0..5ec23829 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -37,6 +37,7 @@ export default gql` updateNoteId(id: ID!, noteId: String!): Item! upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item! act(id: ID!, sats: Int, act: String, hash: String, hmac: String): ItemActResult! + idempotentAct(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult! pollVote(id: ID!, hash: String, hmac: String): ID! } diff --git a/components/comment.js b/components/comment.js index 20b9ba83..bd5f3551 100644 --- a/components/comment.js +++ b/components/comment.js @@ -108,7 +108,6 @@ export default function Comment ({ const ref = useRef(null) const router = useRouter() const root = useRoot() - const [pendingSats, setPendingSats] = useState(0) const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text }) useEffect(() => { @@ -148,7 +147,7 @@ export default function Comment ({
{item.meDontLikeSats > item.meSats ? - : } + : }
{item.user?.meMute && !includeParent && collapse === 'yep' @@ -162,7 +161,6 @@ export default function Comment ({ ) : (meDontLikeSats @@ -23,7 +22,6 @@ export function DownZap ({ id, meDontLikeSats, ...props }) { function DownZapper ({ id, As, children }) { const toaster = useToast() const showModal = useShowModal() - const strike = useLightning() return ( { onClose() toaster.success('item downzapped') - }} itemId={id} strike={strike} down + }} itemId={id} down > { window.localStorage.setItem('custom-tips', JSON.stringify(customTips)) } -export default function ItemAct ({ onClose, itemId, down, strike, children }) { +export default function ItemAct ({ onClose, itemId, down, children }) { const inputRef = useRef(null) const me = useMe() const [oValue, setOValue] = useState() + const strike = useLightning() useEffect(() => { inputRef.current?.focus() @@ -63,7 +67,7 @@ export default function ItemAct ({ onClose, itemId, down, strike, children }) { hmac } }) - strike && await strike() + await strike() addCustomTip(Number(amount)) onClose() }, [act, down, itemId, strike]) @@ -166,3 +170,110 @@ export function useAct ({ onUpdate } = {}) { }`, { update } ) } + +export function useZap () { + const update = useCallback((cache, args) => { + const { data: { idempotentAct: { id, sats, path } } } = args + + // determine how much we increased existing sats by by checking the + // difference between result sats and meSats + // if it's negative, skip the cache as it's an out of order update + // if it's positive, add it to sats and commentSats + + const item = cache.readFragment({ + id: `Item:${id}`, + fragment: gql` + fragment ItemMeSats on Item { + meSats + } + ` + }) + + const satsDelta = sats - item.meSats + + if (satsDelta > 0) { + cache.modify({ + id: `Item:${id}`, + fields: { + sats (existingSats = 0) { + return existingSats + satsDelta + }, + meSats: () => { + return sats + } + } + }) + + // update all ancestors + path.split('.').forEach(aId => { + if (Number(aId) === Number(id)) return + cache.modify({ + id: `Item:${aId}`, + fields: { + commentSats (existingCommentSats = 0) { + return existingCommentSats + satsDelta + } + } + }) + }) + } + }, []) + + const [zap] = useMutation( + gql` + mutation idempotentAct($id: ID!, $sats: Int!, $hash: String, $hmac: String) { + idempotentAct(id: $id, sats: $sats, hash: $hash, hmac: $hmac) { + id + sats + path + } + }`, { update } + ) + + const toaster = useToast() + const strike = useLightning() + const [act] = useAct() + + const showInvoiceModal = useInvoiceModal( + async ({ hash, hmac }, { variables }) => { + await act({ variables: { ...variables, hash, hmac } }) + strike() + }, [act, strike]) + + return useCallback(async ({ item, me }) => { + console.log(item) + const meSats = (item?.meSats || 0) + + // what should our next tip be? + let sats = me?.privates?.tipDefault || 1 + if (me?.privates?.turboTipping) { + while (meSats >= sats) { + sats *= 10 + } + } else { + sats = meSats + sats + } + + const variables = { id: item.id, sats, act: 'TIP' } + try { + await zap({ + variables, + optimisticResponse: { + idempotentAct: { + path: item.path, + ...variables + } + } + }) + } catch (error) { + if (payOrLoginError(error)) { + // call non-idempotent version + const amount = sats - meSats + showInvoiceModal({ amount }, { variables: { ...variables, sats: amount } }) + return + } + console.error(error) + toaster.danger(error?.message || error?.toString?.()) + } + }) +} diff --git a/components/item-info.js b/components/item-info.js index c8123b59..e747b8d6 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -21,7 +21,7 @@ import MuteDropdownItem from './mute' import { DropdownItemUpVote } from './upvote' export default function ItemInfo ({ - item, pendingSats, full, commentsText = 'comments', + item, full, commentsText = 'comments', commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText, onQuoteReply, nofollow, extraBadges }) { @@ -40,8 +40,8 @@ export default function ItemInfo ({ }, [item]) useEffect(() => { - if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats || 0)) - }, [item?.meSats, item?.meAnonSats, pendingSats]) + if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0)) + }, [item?.meSats, item?.meAnonSats]) return (
@@ -57,7 +57,7 @@ export default function ItemInfo ({ ? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}` : ''} from me)`} `} > - {numWithUnits(item.sats + pendingSats)} + {numWithUnits(item.sats)} \ } diff --git a/components/item.js b/components/item.js index 50fd5a7d..ec8a9800 100644 --- a/components/item.js +++ b/components/item.js @@ -1,7 +1,7 @@ import Link from 'next/link' import styles from './item.module.css' import UpVote from './upvote' -import { useRef, useState } from 'react' +import { useRef } from 'react' import { AD_USER_ID, NOFOLLOW_LIMIT } from '../lib/constants' import Pin from '../svgs/pushpin-fill.svg' import reactStringReplace from 'react-string-replace' @@ -27,7 +27,6 @@ export function SearchTitle ({ title }) { export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply }) { const titleRef = useRef() const router = useRouter() - const [pendingSats, setPendingSats] = useState(0) const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL) const nofollow = item.sats + item.boost < NOFOLLOW_LIMIT && !item.position ? 'nofollow' : '' @@ -47,7 +46,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s ? : Number(item.user?.id) === AD_USER_ID ? - : } + : }
}
AD} diff --git a/components/items.js b/components/items.js index d45298eb..b14da3c9 100644 --- a/components/items.js +++ b/components/items.js @@ -54,7 +54,6 @@ export default function Items ({ ssrData, variables = {}, query, destructureData } export function ListItem ({ item, ...props }) { - console.log(item) return ( item.parentId ? diff --git a/components/upvote.js b/components/upvote.js index 5d5ed5eb..49de8211 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -2,7 +2,7 @@ import UpBolt from '../svgs/bolt.svg' import styles from './upvote.module.css' import { gql, useMutation } from '@apollo/client' import ActionTooltip from './action-tooltip' -import ItemAct, { useAct } from './item-act' +import ItemAct, { useAct, useZap } from './item-act' import { useMe } from './me' import getColor from '../lib/rainbow' import { useCallback, useMemo, useRef, useState } from 'react' @@ -10,11 +10,8 @@ import LongPressable from 'react-longpressable' import Overlay from 'react-bootstrap/Overlay' import Popover from 'react-bootstrap/Popover' import { useShowModal } from './modal' -import { LightningConsumer, useLightning } from './lightning' +import { useLightning } from './lightning' import { numWithUnits } from '../lib/format' -import { payOrLoginError, useInvoiceModal } from './invoice' -import useDebounceCallback from './use-debounce-callback' -import { useToast } from './toast' import { Dropdown } from 'react-bootstrap' const UpvotePopover = ({ target, show, handleClose }) => { @@ -58,13 +55,12 @@ const TipPopover = ({ target, show, handleClose }) => ( export function DropdownItemUpVote ({ item }) { const showModal = useShowModal() - const strike = useLightning() return ( { showModal(onClose => - ) + ) }} > zap @@ -72,14 +68,12 @@ export function DropdownItemUpVote ({ item }) { ) } -export default function UpVote ({ item, className, pendingSats, setPendingSats }) { +export default function UpVote ({ item, className }) { const showModal = useShowModal() const [voteShow, _setVoteShow] = useState(false) const [tipShow, _setTipShow] = useState(false) const ref = useRef() const me = useMe() - const strike = useLightning() - const toaster = useToast() const [setWalkthrough] = useMutation( gql` mutation setWalkthrough($upvotePopover: Boolean, $tipPopover: Boolean) { @@ -116,64 +110,35 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } }, [me, tipShow, setWalkthrough]) const [act] = useAct() - - const showInvoiceModal = useInvoiceModal( - async ({ hash, hmac }, { variables }) => { - await act({ variables: { ...variables, hash, hmac } }) - strike() - }, [act, strike]) - - const zap = useDebounceCallback(async (sats) => { - if (!sats) return - const variables = { id: item.id, sats, act: 'TIP' } - - act({ - variables, - optimisticResponse: { - act: { - id: item.id, - sats, - path: item.path, - act: 'TIP' - } - } - }).catch((error) => { - if (payOrLoginError(error)) { - showInvoiceModal({ amount: sats }, { variables }) - return - } - console.error(error) - toaster.danger(error?.message || error?.toString?.()) - }) - setPendingSats(0) - }, 500, [act, toaster, item?.id, item?.path, showInvoiceModal, setPendingSats]) + const zap = useZap() + const strike = useLightning() const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt, [item?.mine, item?.meForward, item?.deletedAt]) - const [meSats, sats, overlayText, color] = useMemo(() => { - const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats + const [meSats, overlayText, color] = useMemo(() => { + const meSats = (item?.meSats || item?.meAnonSats || 0) // what should our next tip be? let sats = me?.privates?.tipDefault || 1 + let raiseSats = sats if (me?.privates?.turboTipping) { - let raiseTip = sats - while (meSats >= raiseTip) { - raiseTip *= 10 + while (meSats >= raiseSats) { + raiseSats *= 10 } - sats = raiseTip - meSats + sats = raiseSats - meSats + } else { + raiseSats = meSats + sats } - return [meSats, sats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it', getColor(meSats)] - }, [item?.meSats, item?.meAnonSats, pendingSats, me?.privates?.tipDefault, me?.privates?.turboDefault]) + return [meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it', getColor(meSats)] + }, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault]) return ( - - {(strike) => -
- + { if (!item) return @@ -184,10 +149,10 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } setTipShow(false) showModal(onClose => - ) + ) } } - onShortPress={ + onShortPress={ me ? async (e) => { if (!item) return @@ -205,41 +170,36 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } strike() - setPendingSats(pendingSats => { - const zapAmount = pendingSats + sats - zap(zapAmount) - return zapAmount - }) + zap({ item, me }) } - : () => showModal(onClose => ) + : () => showModal(onClose => ) } + > + +
- -
- -
-
- - setTipShow(false)} /> - setVoteShow(false)} /> -
} - + style={meSats + ? { + fill: color, + filter: `drop-shadow(0 0 6px ${color}90)` + } + : undefined} + /> +
+ + + setTipShow(false)} /> + setVoteShow(false)} /> +
) } diff --git a/prisma/migrations/20231226230356_item_act_negative/migration.sql b/prisma/migrations/20231226230356_item_act_negative/migration.sql new file mode 100644 index 00000000..a701daeb --- /dev/null +++ b/prisma/migrations/20231226230356_item_act_negative/migration.sql @@ -0,0 +1,88 @@ +-- Update item_act to noop on negative or zero sats +CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats BIGINT; + act_msats BIGINT; + fee_msats BIGINT; + item_act_id INTEGER; + fwd_entry record; -- for loop iterator variable to iterate across forward recipients + fwd_msats BIGINT; -- for loop variable calculating how many msats to give each forward recipient + total_fwd_msats BIGINT := 0; -- accumulator to see how many msats have been forwarded for the act +BEGIN + PERFORM ASSERT_SERIALIZED(); + + IF act_sats <= 0 THEN + RETURN 0; + END IF; + + act_msats := act_sats * 1000; + SELECT msats INTO user_msats FROM users WHERE id = user_id; + IF act_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- deduct msats from actor + UPDATE users SET msats = msats - act_msats WHERE id = user_id; + + IF act = 'TIP' THEN + -- call to influence weightedVotes ... we need to do this before we record the acts because + -- the priors acts are taken into account + PERFORM weighted_votes_after_tip(item_id, user_id, act_sats); + -- call to denormalize sats and commentSats + PERFORM sats_after_tip(item_id, user_id, act_msats); + + -- take 10% and insert as FEE + fee_msats := CEIL(act_msats * 0.1); + act_msats := act_msats - fee_msats; + + -- save the fee act into item_act_id so we can record referral acts + INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at) + VALUES (fee_msats, item_id, user_id, 'FEE', now_utc(), now_utc()) + RETURNING id INTO item_act_id; + + -- leave the rest as a tip + INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at) + VALUES (act_msats, item_id, user_id, 'TIP', now_utc(), now_utc()); + + -- denormalize bounty paid (if applicable) + PERFORM bounty_paid_after_act(item_id, user_id); + + -- add sats to actees' balance and stacked count + FOR fwd_entry IN SELECT "userId", "pct" FROM "ItemForward" WHERE "itemId" = item_id + LOOP + -- fwd_msats represents the sats for this forward recipient from this particular tip action + fwd_msats := act_msats * fwd_entry.pct / 100; + -- keep track of how many msats have been forwarded, so we can give any remaining to OP + total_fwd_msats := fwd_msats + total_fwd_msats; + + UPDATE users + SET msats = msats + fwd_msats, "stackedMsats" = "stackedMsats" + fwd_msats + WHERE id = fwd_entry."userId"; + END LOOP; + + -- Give OP any remaining msats after forwards have been applied + IF act_msats - total_fwd_msats > 0 THEN + UPDATE users + SET msats = msats + act_msats - total_fwd_msats, "stackedMsats" = "stackedMsats" + act_msats - total_fwd_msats + WHERE id = (SELECT "userId" FROM "Item" WHERE id = item_id); + END IF; + ELSE -- BOOST, POLL, DONT_LIKE_THIS, STREAM + -- call to influence if DONT_LIKE_THIS weightedDownVotes + IF act = 'DONT_LIKE_THIS' THEN + PERFORM weighted_downvotes_after_act(item_id, user_id, act_sats); + END IF; + + INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at) + VALUES (act_msats, item_id, user_id, act, now_utc(), now_utc()) + RETURNING id INTO item_act_id; + END IF; + + -- store referral effects + PERFORM referral_act(item_act_id); + + RETURN 0; +END; +$$; \ No newline at end of file