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 (