diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 9f4caa9e2..b254bbca4 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -17,6 +17,7 @@ import { sendUserNotification } from '../webPush' import { proxyImages } from './imgproxy' import { defaultCommentSort } from '../../lib/item' import { createHmac } from './wallet' +import { settleHodlInvoice } from 'ln-service' export async function commentFilterClause (me, models) { let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` @@ -52,15 +53,27 @@ async function checkInvoice (models, hash, hmac, fee) { user: true } }) + if (!invoice) { throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) } + + const expired = new Date(invoice.expiresAt) <= new Date() + if (expired) { + throw new GraphQLError('invoice expired', { extensions: { code: 'BAD_INPUT' } }) + } + + if (invoice.cancelled) { + throw new GraphQLError('invoice was canceled', { extensions: { code: 'BAD_INPUT' } }) + } + if (!invoice.msatsReceived) { throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } }) } - if (msatsToSats(invoice.msatsReceived) < fee) { + if (fee && msatsToSats(invoice.msatsReceived) < fee) { throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } }) } + return invoice } @@ -604,34 +617,34 @@ export default { return await models.item.update({ where: { id: Number(id) }, data }) }, - upsertLink: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { + upsertLink: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { await ssValidate(linkSchema, item, models) if (id) { return await updateItem(parent, { id, ...item }, { me, models }) } else { - return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) + return await createItem(parent, item, { me, models, lnd, hash, hmac }) } }, - upsertDiscussion: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { + upsertDiscussion: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { await ssValidate(discussionSchema, item, models) if (id) { return await updateItem(parent, { id, ...item }, { me, models }) } else { - return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) + return await createItem(parent, item, { me, models, lnd, hash, hmac }) } }, - upsertBounty: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { + upsertBounty: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { await ssValidate(bountySchema, item, models) if (id) { return await updateItem(parent, { id, ...item }, { me, models }) } else { - return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) + return await createItem(parent, item, { me, models, lnd, hash, hmac }) } }, - upsertPoll: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { + upsertPoll: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { const optionCount = id ? await models.pollOption.count({ where: { @@ -646,10 +659,10 @@ export default { return await updateItem(parent, { id, ...item }, { me, models }) } else { item.pollCost = item.pollCost || POLL_COST - return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) + return await createItem(parent, item, { me, models, lnd, hash, hmac }) } }, - upsertJob: async (parent, { id, ...item }, { me, models }) => { + upsertJob: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { if (!me) { throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } }) } @@ -665,16 +678,16 @@ export default { if (id) { return await updateItem(parent, { id, ...item }, { me, models }) } else { - return await createItem(parent, item, { me, models }) + return await createItem(parent, item, { me, models, lnd, hash, hmac }) } }, - upsertComment: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { + upsertComment: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { await ssValidate(commentSchema, item) if (id) { return await updateItem(parent, { id, ...item }, { me, models }) } else { - const rItem = await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) + const rItem = await createItem(parent, item, { me, models, lnd, hash, hmac }) const notify = async () => { const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) @@ -706,19 +719,19 @@ export default { return id }, - act: async (parent, { id, sats, invoiceHash, invoiceHmac }, { me, models }) => { + act: async (parent, { id, sats, hash, hmac }, { me, models, lnd }) => { // need to make sure we are logged in - if (!me && !invoiceHash) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + if (!me && !hash) { + throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } }) } await ssValidate(amountSchema, { amount: sats }) let user = me let invoice - if (!me && invoiceHash) { - invoice = await checkInvoice(models, invoiceHash, invoiceHmac, sats) - user = invoice.user + if (hash) { + invoice = await checkInvoice(models, hash, hmac, sats) + if (!me) user = invoice.user } // disallow self tips except anons @@ -738,14 +751,18 @@ export default { throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } }) } - const calls = [ + const trx = [ models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` ] if (invoice) { - calls.push(models.invoice.delete({ where: { hash: invoice.hash } })) + trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`) + trx.push(models.invoice.delete({ where: { hash: invoice.hash } })) } - const [{ item_act: vote }] = await serialize(models, ...calls) + const query = await serialize(models, ...trx) + const { item_act: vote } = trx.length > 1 ? query[1][0] : query[0] + + if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd }) const notify = async () => { try { @@ -1098,24 +1115,27 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it return item } -export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, invoiceHash, invoiceHmac }) => { - let spamInterval = ITEM_SPAM_INTERVAL - const trx = [] +export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, hash, hmac }) => { + const spamInterval = me ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL // rename to match column name item.subName = item.sub delete item.sub + if (!me && !hash) { + throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } }) + } + let invoice + if (hash) { + // if we are logged in, we don't compare the invoice amount with the fee + // since it's not a fixed amount that we could use here. + // we rely on the query telling us if the balance is too low + const fee = !me ? (item.parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) : undefined + invoice = await checkInvoice(models, hash, hmac, fee) + item.userId = invoice.user.id + } if (me) { item.userId = Number(me.id) - } else { - if (!invoiceHash) { - throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } }) - } - const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, item.parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) - item.userId = invoice.user.id - spamInterval = ANON_ITEM_SPAM_INTERVAL - trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } const fwdUsers = await getForwardUsers(models, forward) @@ -1128,12 +1148,19 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo item.url = await proxyImages(item.url) } - const [result] = await serialize( - models, + const trx = [ models.$queryRawUnsafe(`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`, - JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)), - ...trx) - item = Array.isArray(result) ? result[0] : result + JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)) + ] + if (invoice) { + trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`) + trx.push(models.invoice.delete({ where: { hash: invoice.hash } })) + } + + const query = await serialize(models, ...trx) + item = trx.length > 1 ? query[1][0] : query[0] + + if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd }) await createMentions(item, models) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index a48095d0c..1f9646a5d 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,4 +1,4 @@ -import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service' +import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice } from 'ln-service' import { GraphQLError } from 'graphql' import crypto from 'crypto' import serialize from './serial' @@ -11,7 +11,7 @@ import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../l 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 }) { +export async function getInvoice (parent, { id }, { me, models, lnd }) { const inv = await models.invoice.findUnique({ where: { id: Number(id) @@ -24,6 +24,7 @@ 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 } @@ -223,7 +224,7 @@ export default { }, Mutation: { - createInvoice: async (parent, { amount, expireSecs = 3600 }, { me, models, lnd }) => { + createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd }) => { await ssValidate(amountSchema, { amount }) let expirePivot = { seconds: expireSecs } @@ -242,7 +243,7 @@ export default { const expiresAt = datePivot(new Date(), expirePivot) const description = `Funding @${user.name} on stacker.news` try { - const invoice = await createInvoice({ + const invoice = await (hodlInvoice ? createHodlInvoice : createInvoice)({ description: user.hideInvoiceDesc ? undefined : description, lnd, tokens: amount, @@ -254,6 +255,8 @@ export default { ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, ${invLimit}::INTEGER, ${balanceLimit})`) + if (hodlInvoice) await models.invoice.update({ where: { hash: invoice.id }, data: { preimage: invoice.secret } }) + // the HMAC is only returned during invoice creation // this makes sure that only the person who created this invoice // has access to the HMAC @@ -312,6 +315,23 @@ export default { // take pr and createWithdrawl return await createWithdrawal(parent, { invoice: res2.pr, maxFee }, { me, models, lnd }) + }, + cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => { + const hmac2 = createHmac(hash) + if (hmac !== hmac2) { + throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) + } + await cancelHodlInvoice({ id: hash, lnd }) + const inv = await serialize(models, + models.invoice.update({ + where: { + hash + }, + data: { + cancelled: true + } + })) + return inv } }, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index e3c8437b6..4156f4697 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -26,15 +26,15 @@ 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: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item! - upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item! - upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: [ItemForwardInput]): Item! + upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item! + upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item! + upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): 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: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item! - upsertComment(id:ID, text: String!, parentId: ID, invoiceHash: String, invoiceHmac: String): Item! + text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item! + upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item! + upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item! dontLikeThis(id: ID!): Boolean! - act(id: ID!, sats: Int, invoiceHash: String, invoiceHmac: String): ItemActResult! + act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult! pollVote(id: ID!): ID! } diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 6eefe9997..1e04752e0 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -9,9 +9,10 @@ export default gql` } extend type Mutation { - createInvoice(amount: Int!, expireSecs: Int): Invoice! + createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice! createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!): Withdrawl! + cancelInvoice(hash: String!, hmac: String!): Invoice! } type Invoice { @@ -26,6 +27,7 @@ export default gql` satsRequested: Int! nostr: JSONObject hmac: String + isHeld: Boolean } type Withdrawl { diff --git a/components/bounty-form.js b/components/bounty-form.js index 534884d3a..7d0696e37 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -9,7 +9,6 @@ import { bountySchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' import { useCallback } from 'react' -import { useInvoiceable } from './invoice' import { normalizeForwards } from '../lib/form' export function BountyForm ({ @@ -36,6 +35,8 @@ export function BountyForm ({ $text: String $boost: Int $forward: [ItemForwardInput] + $hash: String + $hmac: String ) { upsertBounty( sub: $sub @@ -45,6 +46,8 @@ export function BountyForm ({ text: $text boost: $boost forward: $forward + hash: $hash + hmac: $hmac ) { id } @@ -52,9 +55,8 @@ export function BountyForm ({ ` ) - const submitUpsertBounty = useCallback( - // we ignore the invoice since only stackers can post bounties - async (_, boost, bounty, values, ...__) => { + const onSubmit = useCallback( + async ({ boost, bounty, ...values }) => { const { error } = await upsertBounty({ variables: { sub: item?.subName || sub?.name, @@ -75,9 +77,8 @@ export function BountyForm ({ const prefix = sub?.name ? `/~${sub.name}` : '' await router.push(prefix + '/recent') } - }, [upsertBounty, router]) - - const invoiceableUpsertBounty = useInvoiceable(submitUpsertBounty, { requireSession: true }) + }, [upsertBounty, router] + ) return (
{ - return invoiceableUpsertBounty(cost, boost, bounty, values) - }) + onSubmit } storageKeyPrefix={item ? undefined : 'bounty'} > diff --git a/components/discussion-form.js b/components/discussion-form.js index 5679d1eda..08b155756 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -13,7 +13,6 @@ import { discussionSchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' import { useCallback } from 'react' -import { useInvoiceable } from './invoice' import { normalizeForwards } from '../lib/form' export function DiscussionForm ({ @@ -30,24 +29,22 @@ export function DiscussionForm ({ // const me = useMe() const [upsertDiscussion] = useMutation( gql` - mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) { - upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { + mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { + upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { id } }` ) - const submitUpsertDiscussion = useCallback( - async (_, boost, values, invoiceHash, invoiceHmac) => { + const onSubmit = useCallback( + async ({ boost, ...values }) => { const { error } = await upsertDiscussion({ variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, - forward: normalizeForwards(values.forward), - invoiceHash, - invoiceHmac + forward: normalizeForwards(values.forward) } }) if (error) { @@ -60,9 +57,8 @@ export function DiscussionForm ({ const prefix = sub?.name ? `/~${sub.name}` : '' await router.push(prefix + '/recent') } - }, [upsertDiscussion, router]) - - const invoiceableUpsertDiscussion = useInvoiceable(submitUpsertDiscussion) + }, [upsertDiscussion, router] + ) const [getRelated, { data: relatedData }] = useLazyQuery(gql` ${ITEM_FIELDS} @@ -87,9 +83,8 @@ export function DiscussionForm ({ ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} - onSubmit={handleSubmit || (async ({ boost, cost, ...values }) => { - return invoiceableUpsertDiscussion(cost, boost, values) - })} + invoiceable + onSubmit={handleSubmit || onSubmit} storageKeyPrefix={item ? undefined : 'discussion'} > {children} diff --git a/components/form.js b/components/form.js index cedcc584f..46f219a7d 100644 --- a/components/form.js +++ b/components/form.js @@ -19,19 +19,24 @@ import { useLazyQuery } from '@apollo/client' import { USER_SEARCH } from '../fragments/users' import TextareaAutosize from 'react-textarea-autosize' import { useToast } from './toast' +import { useInvoiceable } from './invoice' export function SubmitButton ({ - children, variant, value, onClick, disabled, ...props + children, variant, value, onClick, disabled, cost, ...props }) { - const { isSubmitting, setFieldValue } = useFormikContext() + const formik = useFormikContext() + useEffect(() => { + formik?.setFieldValue('cost', cost) + }, [formik?.setFieldValue, formik?.getFieldProps('cost').value, cost]) + return ( or - + ) } -export const isInsufficientFundsError = (error) => { +export const payOrLoginError = (error) => { + const matches = ['insufficient funds', 'you must be logged in or pay'] if (Array.isArray(error)) { - return error.some(({ message }) => message.includes('insufficient funds')) + return error.some(({ message }) => matches.some(m => message.includes(m))) } - return error.toString().includes('insufficient funds') + return matches.some(m => error.toString().includes(m)) } diff --git a/components/invoice.js b/components/invoice.js index 172f96307..d0835efdf 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -5,38 +5,39 @@ 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' -import { usePaymentTokens } from './payment-tokens' +import FundError, { payOrLoginError } from './fund-error' +import Countdown from './countdown' + +export function Invoice ({ invoice, onPayment, successVerb }) { + const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date()) -export function Invoice ({ invoice, onConfirmation, successVerb }) { let variant = 'default' let status = 'waiting for you' let webLn = true - if (invoice.confirmedAt) { + if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) { variant = 'confirmed' status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}` webLn = false - } else if (invoice.cancelled) { + } else if (expired) { variant = 'failed' - status = 'cancelled' + status = 'expired' webLn = false - } else if (invoice.expiresAt <= new Date()) { + } else if (invoice.cancelled) { variant = 'failed' - status = 'expired' + status = 'cancelled' webLn = false } useEffect(() => { - if (invoice.confirmedAt) { - onConfirmation?.(invoice) + if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived)) { + onPayment?.(invoice) } - }, [invoice.confirmedAt]) + }, [invoice.confirmedAt, invoice.isHeld, invoice.satsReceived]) const { nostr } = invoice @@ -47,6 +48,13 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) { description={numWithUnits(invoice.satsRequested, { abbreviate: false })} statusVariant={variant} status={status} /> +
+ { + setExpired(true) + }} + /> +
{nostr ? { - const subject = `Support request for payment hash: ${invoiceHash}` - const body = 'Hi, I successfully paid for but the action did not work.' - return ( -
-
- payment token save this} - type='text' placeholder={invoiceHash + '|' + invoiceHmac} readOnly noForm - /> -
- -
- ) -} - -const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => { +const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresAt, ...props }) => { const { data, loading, error } = useQuery(INVOICE, { pollInterval: 1000, variables: { id } }) + const [cancelInvoice] = useMutation(gql` + mutation cancelInvoice($hash: String!, $hmac: String!) { + cancelInvoice(hash: $hash, hmac: $hmac) { + id + } + } + `) if (error) { if (error.message?.includes('invoice not found')) { return @@ -126,7 +97,7 @@ const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => { 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.' + errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.' } return ( <> @@ -137,8 +108,17 @@ const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => {
-
- +
+ + +
) : null} @@ -150,64 +130,67 @@ const defaultOptions = { forceInvoice: false, requireSession: false } -export const useInvoiceable = (fn, options = defaultOptions) => { +export const useInvoiceable = (onSubmit, options = defaultOptions) => { const me = useMe() const [createInvoice, { data }] = useMutation(gql` mutation createInvoice($amount: Int!) { - createInvoice(amount: $amount, expireSecs: 1800) { + createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) { id hash hmac + expiresAt } }`) const showModal = useShowModal() - const [fnArgs, setFnArgs] = useState() - const { addPaymentToken, removePaymentToken } = usePaymentTokens() + const [formValues, setFormValues] = useState() + const [submitArgs, setSubmitArgs] = useState() - // fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice let errorCount = 0 - const onConfirmation = useCallback( + const onPayment = useCallback( (onClose, hmac) => { - return async ({ id, satsReceived, hash }) => { - addPaymentToken(hash, hmac, satsReceived) + return async ({ id, satsReceived, expiresAt, hash }) => { await sleep(500) const repeat = () => - fn(satsReceived, ...fnArgs, hash, hmac) - .then(() => { - removePaymentToken(hash, hmac) - }) + // call onSubmit handler and pass invoice data + onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs) .then(onClose) .catch((error) => { + // if error happened after payment, show repeat and cancel options + // by passing `errorCount` and `repeat` console.error(error) errorCount++ onClose() showModal(onClose => ( - ), { keepOpen: true }) }) - // prevents infinite loop of calling `onConfirmation` + // prevents infinite loop of calling `onPayment` if (errorCount === 0) await repeat() } - }, [fn, fnArgs] + }, [onSubmit, submitArgs] ) const invoice = data?.createInvoice useEffect(() => { if (invoice) { showModal(onClose => ( - ), { keepOpen: true } @@ -215,21 +198,31 @@ export const useInvoiceable = (fn, options = defaultOptions) => { } }, [invoice?.id]) - const actionFn = useCallback(async (amount, ...args) => { + // this function will be called before the Form's onSubmit handler is called + // and the form must include `cost` or `amount` as a value + const onSubmitWrapper = useCallback(async (formValues, ...submitArgs) => { + let { cost, amount } = formValues + cost ??= amount + + // action only allowed if logged in if (!me && options.requireSession) { throw new Error('you must be logged in') } - if (!amount || (me && !options.forceInvoice)) { + + // if no cost is passed, just try the action first + if (!cost || (me && !options.forceInvoice)) { try { - return await fn(amount, ...args) + return await onSubmit(formValues, ...submitArgs) } catch (error) { - if (isInsufficientFundsError(error)) { + if (payOrLoginError(error)) { showModal(onClose => { return ( { await fn(amount, ...args, invoiceHash, invoiceHmac) }} + amount={cost} + onPayment={async ({ satsReceived, hash, hmac }) => { + await onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs) + }} /> ) }) @@ -238,12 +231,13 @@ export const useInvoiceable = (fn, options = defaultOptions) => { throw error } } - setFnArgs(args) - await createInvoice({ variables: { amount } }) + setFormValues(formValues) + setSubmitArgs(submitArgs) + await createInvoice({ variables: { amount: cost } }) // tell onSubmit handler that we want to keep local storage // even though the submit handler was "successful" return { keepLocalStorage: true } - }, [fn, setFnArgs, createInvoice]) + }, [onSubmit, setFormValues, setSubmitArgs, createInvoice]) - return actionFn + return onSubmitWrapper } diff --git a/components/item-act.js b/components/item-act.js index 1f32bfc34..b2e0b7d32 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -5,7 +5,6 @@ import { Form, Input, SubmitButton } from './form' import { useMe } from './me' import UpBolt from '../svgs/bolt.svg' import { amountSchema } from '../lib/validate' -import { useInvoiceable } from './invoice' const defaultTips = [100, 1000, 10000, 100000] @@ -46,27 +45,24 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { inputRef.current?.focus() }, [onClose, itemId]) - const submitAct = useCallback( - async (amount, invoiceHash, invoiceHmac) => { - if (!me) { - const storageKey = `TIP-item:${itemId}` - const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') - window.localStorage.setItem(storageKey, existingAmount + amount) + const onSubmit = useCallback(async ({ amount, hash, hmac }) => { + 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), + hash, + hmac } - await act({ - variables: { - id: itemId, - sats: Number(amount), - invoiceHash, - invoiceHmac - } - }) - await strike() - addCustomTip(Number(amount)) - onClose() - }, [act, onClose, strike, itemId]) - - const invoiceableAct = useInvoiceable(submitAct) + }) + await strike() + addCustomTip(Number(amount)) + onClose() + }, [act]) return ( { - return invoiceableAct(amount) - }} + invoiceable + onSubmit={onSubmit} > { + const onSubmit = useCallback( + async ({ maxBid, start, stop, ...values }) => { let status if (start) { status = 'ACTIVE' @@ -80,9 +78,8 @@ export default function JobForm ({ item, sub }) { } else { await router.push(`/~${sub.name}/recent`) } - }, [upsertJob, router, item?.id, sub?.name, logoId]) - - const invoiceableUpsertJob = useInvoiceable(submitUpsertJob, { requireSession: true }) + }, [upsertJob, router] + ) return ( <> @@ -101,9 +98,8 @@ export default function JobForm ({ item, sub }) { }} schema={jobSchema} storageKeyPrefix={storageKeyPrefix} - onSubmit={(async ({ maxBid, stop, start, ...values }) => { - return invoiceableUpsertJob(1000, maxBid, stop, start, values) - })} + invoiceable={{ requireSession: true }} + onSubmit={onSubmit} >
@@ -167,7 +163,7 @@ export default function JobForm ({ item, sub }) { ) : ( - post 1000 sats + post 1000 sats )}
diff --git a/components/link-form.js b/components/link-form.js index 54b38f582..d5f2c2430 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -14,7 +14,6 @@ import { linkSchema } from '../lib/validate' import Moon from '../svgs/moon-fill.svg' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' -import { useInvoiceable } from './invoice' import { normalizeForwards } from '../lib/form' export function LinkForm ({ item, sub, editThreshold, children }) { @@ -68,23 +67,21 @@ export function LinkForm ({ item, sub, editThreshold, children }) { const [upsertLink] = useMutation( gql` - mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) { - upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { + mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { + upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { id } }` ) - const submitUpsertLink = useCallback( - async (_, boost, title, values, invoiceHash, invoiceHmac) => { + const onSubmit = useCallback( + 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(), - invoiceHash, - invoiceHmac, ...values, forward: normalizeForwards(values.forward) } @@ -98,9 +95,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) { const prefix = sub?.name ? `/~${sub.name}` : '' await router.push(prefix + '/recent') } - }, [upsertLink, router]) - - const invoiceableUpsertLink = useInvoiceable(submitUpsertLink) + }, [upsertLink, router] + ) useEffect(() => { if (data?.pageTitleAndUnshorted?.title) { @@ -128,9 +124,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) { ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} - onSubmit={async ({ boost, title, cost, ...values }) => { - return invoiceableUpsertLink(cost, boost, title, values) - }} + invoiceable + onSubmit={onSubmit} storageKeyPrefix={item ? undefined : 'link'} > {children} diff --git a/components/payment-tokens.js b/components/payment-tokens.js deleted file mode 100644 index 3ac8a63dd..000000000 --- a/components/payment-tokens.js +++ /dev/null @@ -1,40 +0,0 @@ -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/components/poll-form.js b/components/poll-form.js index 19cb72439..2dc7db803 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -11,7 +11,6 @@ import { pollSchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import CancelButton from './cancel-button' import { useCallback } from 'react' -import { useInvoiceable } from './invoice' import { normalizeForwards } from '../lib/form' export function PollForm ({ item, sub, editThreshold, children }) { @@ -22,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: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) { + $options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { upsertPoll(sub: $sub, id: $id, title: $title, text: $text, - options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { + options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { id } }` ) - const submitUpsertPoll = useCallback( - async (_, boost, title, options, values, invoiceHash, invoiceHmac) => { + const onSubmit = useCallback( + async ({ boost, title, options, ...values }) => { const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) const { error } = await upsertPoll({ variables: { @@ -41,9 +40,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { title: title.trim(), options: optionsFiltered, ...values, - forward: normalizeForwards(values.forward), - invoiceHash, - invoiceHmac + forward: normalizeForwards(values.forward) } }) if (error) { @@ -55,9 +52,8 @@ export function PollForm ({ item, sub, editThreshold, children }) { const prefix = sub?.name ? `/~${sub.name}` : '' await router.push(prefix + '/recent') } - }, [upsertPoll, router]) - - const invoiceableUpsertPoll = useInvoiceable(submitUpsertPoll) + }, [upsertPoll, router] + ) const initialOptions = item?.poll?.options.map(i => i.option) @@ -71,9 +67,8 @@ export function PollForm ({ item, sub, editThreshold, children }) { ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} - onSubmit={async ({ boost, title, options, cost, ...values }) => { - return invoiceableUpsertPoll(cost, boost, title, options, values) - }} + invoiceable + onSubmit={onSubmit} storageKeyPrefix={item ? undefined : 'poll'} > {children} diff --git a/components/reply.js b/components/reply.js index d1c107f8f..0dd9bf98b 100644 --- a/components/reply.js +++ b/components/reply.js @@ -9,7 +9,6 @@ import FeeButton from './fee-button' import { commentsViewedAfterComment } from '../lib/new-comments' import { commentSchema } from '../lib/validate' import Info from './info' -import { useInvoiceable } from './invoice' export function ReplyOnAnotherPage ({ parentId }) { return ( @@ -46,8 +45,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold const [upsertComment] = useMutation( gql` ${COMMENTS} - mutation upsertComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) { - upsertComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { + mutation upsertComment($text: String!, $parentId: ID!, $hash: String, $hmac: String) { + upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) { ...CommentFields comments { ...CommentsRecursive @@ -91,17 +90,11 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold } ) - const submitComment = useCallback( - async (_, values, parentId, resetForm, invoiceHash, invoiceHmac) => { - const { error } = await upsertComment({ variables: { ...values, parentId, invoiceHash, invoiceHmac } }) - if (error) { - throw new Error({ message: error.toString() }) - } - resetForm({ text: '' }) - setReply(replyOpen || false) - }, [upsertComment, setReply]) - - const invoiceableCreateComment = useInvoiceable(submitComment) + const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => { + await upsertComment({ variables: { parentId, hash, hmac, ...values } }) + resetForm({ text: '' }) + setReply(replyOpen || false) + }, [upsertComment, setReply]) const replyInput = useRef(null) useEffect(() => { @@ -129,9 +122,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold text: '' }} schema={commentSchema} - onSubmit={async ({ cost, ...values }, { resetForm }) => { - return invoiceableCreateComment(cost, values, parentId, resetForm) - }} + invoiceable + onSubmit={onSubmit} storageKeyPrefix={'reply-' + parentId} > { return ( { - await act({ variables: { ...variables, invoiceHash } }) + onPayment={async ({ hash, hmac }) => { + await act({ variables: { ...variables, hash, hmac } }) strike() }} /> diff --git a/fragments/wallet.js b/fragments/wallet.js index 0315ed4f5..3932850a5 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -13,6 +13,7 @@ export const INVOICE = gql` confirmedAt expiresAt nostr + isHeld } }` diff --git a/pages/_app.js b/pages/_app.js index c2a7a6093..5b912ceee 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -15,7 +15,6 @@ 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 @@ -92,11 +91,9 @@ function MyApp ({ Component, pageProps: { ...props } }) { - - - - - + + + diff --git a/prisma/migrations/20230822133848_invoice_preimages/migration.sql b/prisma/migrations/20230822133848_invoice_preimages/migration.sql new file mode 100644 index 000000000..e6d939a75 --- /dev/null +++ b/prisma/migrations/20230822133848_invoice_preimages/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[preimage]` on the table `Invoice` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "preimage" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Invoice.preimage_unique" ON "Invoice"("preimage"); diff --git a/prisma/migrations/20230824124501_invoice_is_held/migration.sql b/prisma/migrations/20230824124501_invoice_is_held/migration.sql new file mode 100644 index 000000000..75c9d9403 --- /dev/null +++ b/prisma/migrations/20230824124501_invoice_is_held/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "isHeld" BOOLEAN; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82ea8bef0..8a2e2a908 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -420,6 +420,8 @@ model Invoice { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") userId Int hash String @unique(map: "Invoice.hash_unique") + preimage String? @unique(map: "Invoice.preimage_unique") + isHeld Boolean? bolt11 String expiresAt DateTime confirmedAt DateTime? diff --git a/worker/nostr.js b/worker/nostr.js index 453ea1fba..c69482d25 100644 --- a/worker/nostr.js +++ b/worker/nostr.js @@ -22,6 +22,9 @@ function nip57 ({ boss, lnd, models }) { return } + // check if invoice still exists since HODL invoices get deleted after usage + if (!inv) return + try { // if parsing fails it's not a zap console.log('zapping', inv.desc) diff --git a/worker/wallet.js b/worker/wallet.js index 745760344..30501d754 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -1,12 +1,12 @@ const serialize = require('../api/resolvers/serial') -const { getInvoice, getPayment } = require('ln-service') +const { getInvoice, getPayment, cancelHodlInvoice } = 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 } }) { + return async function ({ data: { hash, isHeldSet } }) { let inv try { inv = await getInvoice({ id: hash, lnd }) @@ -18,13 +18,20 @@ function checkInvoice ({ boss, models, lnd }) { } console.log(inv) + // check if invoice still exists since HODL invoices get deleted after usage + const dbInv = await models.invoice.findUnique({ where: { hash } }) + if (!dbInv) return + + const expired = new Date(inv.expires_at) <= new Date() + if (inv.is_confirmed) { await serialize(models, models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`) - await boss.send('nip57', { hash }) - } else if (inv.is_canceled) { - // mark as cancelled - await serialize(models, + return boss.send('nip57', { hash }) + } + + if (inv.is_canceled) { + return serialize(models, models.invoice.update({ where: { hash: inv.id @@ -33,11 +40,27 @@ function checkInvoice ({ boss, models, lnd }) { cancelled: true } })) - } else if (new Date(inv.expires_at) > new Date()) { - // not expired, recheck in 5 seconds if the invoice is younger than 5 minutes + } + + if (inv.is_held && !isHeldSet) { + // this is basically confirm_invoice without setting confirmed_at since it's not settled yet + // and without setting the user balance since that's done inside the same tx as the HODL invoice action. + await serialize(models, + models.invoice.update({ where: { hash }, data: { msatsReceived: Number(inv.received_mtokens), isHeld: true } })) + // remember that we already executed this if clause + // (even though the query above is idempotent but imo, this makes the flow more clear) + isHeldSet = true + } + + if (!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 }) + await boss.send('checkInvoice', { hash, isHeldSet }, { ...walletOptions, startAfter }) + } + + if (expired && inv.is_held) { + await cancelHodlInvoice({ id: hash, lnd }) } } }