Use HODL invoices (#432)

* Use HODL invoices

* Fix expiry check comparing string with Date

* Fix unconfirmed user balance for HODL invoices

This is done by syncing the data from LND to the Invoice table.

If the columns is_held and msatsReceived are set, the frontend is told that we're ready to execute the action.

We then update the user balance in the same tx as the action.

We need to still keep checking the invoice for expiration though.

* Fix worker acting upon deleted invoices

* Prevent usage of invoice after expiration

* Use onComplete from <Countdown> to show expired status

* Remove unused lnd argument

* Fix item destructuring from query

* Fix balance added to every stacker

* Fix hmac required

* Fix invoices not used when logged in

* refactor: move invoiceable code into form

* renamed invoiceHash, invoiceHmac to hash, hmac since it's less verbose all over the place
* form now supports `invoiceable` in its props
* form then wraps `onSubmit` with `useInvoiceable` and passes optional invoice options

* Show expired if expired and canceled

* Also use useCallback for zapping

* Always expire modal invoices after 3m

* little styling thing

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
ekzyis 2023-08-31 04:48:49 +02:00 committed by GitHub
parent c6dfd1e39c
commit ac45fdc234
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 339 additions and 313 deletions

View File

@ -17,6 +17,7 @@ import { sendUserNotification } from '../webPush'
import { proxyImages } from './imgproxy' import { proxyImages } from './imgproxy'
import { defaultCommentSort } from '../../lib/item' import { defaultCommentSort } from '../../lib/item'
import { createHmac } from './wallet' import { createHmac } from './wallet'
import { settleHodlInvoice } from 'ln-service'
export async function commentFilterClause (me, models) { export async function commentFilterClause (me, models) {
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
@ -52,15 +53,27 @@ async function checkInvoice (models, hash, hmac, fee) {
user: true user: true
} }
}) })
if (!invoice) { if (!invoice) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) 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) { if (!invoice.msatsReceived) {
throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } }) 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' } }) throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } })
} }
return invoice return invoice
} }
@ -604,34 +617,34 @@ export default {
return await models.item.update({ where: { id: Number(id) }, data }) 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) await ssValidate(linkSchema, item, models)
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models })
} else { } 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) await ssValidate(discussionSchema, item, models)
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models })
} else { } 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) await ssValidate(bountySchema, item, models)
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models })
} else { } 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 const optionCount = id
? await models.pollOption.count({ ? await models.pollOption.count({
where: { where: {
@ -646,10 +659,10 @@ export default {
return await updateItem(parent, { id, ...item }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models })
} else { } else {
item.pollCost = item.pollCost || POLL_COST 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) { if (!me) {
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
} }
@ -665,16 +678,16 @@ export default {
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models })
} else { } 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) await ssValidate(commentSchema, item)
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models })
} else { } 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 notify = async () => {
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
@ -706,19 +719,19 @@ export default {
return id 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 // need to make sure we are logged in
if (!me && !invoiceHash) { if (!me && !hash) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } })
} }
await ssValidate(amountSchema, { amount: sats }) await ssValidate(amountSchema, { amount: sats })
let user = me let user = me
let invoice let invoice
if (!me && invoiceHash) { if (hash) {
invoice = await checkInvoice(models, invoiceHash, invoiceHmac, sats) invoice = await checkInvoice(models, hash, hmac, sats)
user = invoice.user if (!me) user = invoice.user
} }
// disallow self tips except anons // 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' } }) 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)` models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
] ]
if (invoice) { 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 () => { const notify = async () => {
try { try {
@ -1098,24 +1115,27 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
return item return item
} }
export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, invoiceHash, invoiceHmac }) => { export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
let spamInterval = ITEM_SPAM_INTERVAL const spamInterval = me ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL
const trx = []
// rename to match column name // rename to match column name
item.subName = item.sub item.subName = item.sub
delete 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) { if (me) {
item.userId = Number(me.id) 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) 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) item.url = await proxyImages(item.url)
} }
const [result] = await serialize( const trx = [
models,
models.$queryRawUnsafe(`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`, 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)), JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options))
...trx) ]
item = Array.isArray(result) ? result[0] : result 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) await createMentions(item, models)

View File

@ -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 { GraphQLError } from 'graphql'
import crypto from 'crypto' import crypto from 'crypto'
import serialize from './serial' 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 { 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' 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({ const inv = await models.invoice.findUnique({
where: { where: {
id: Number(id) id: Number(id)
@ -24,6 +24,7 @@ export async function getInvoice (parent, { id }, { me, models }) {
if (!inv) { if (!inv) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
} }
if (inv.user.id === ANON_USER_ID) { if (inv.user.id === ANON_USER_ID) {
return inv return inv
} }
@ -223,7 +224,7 @@ export default {
}, },
Mutation: { 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 }) await ssValidate(amountSchema, { amount })
let expirePivot = { seconds: expireSecs } let expirePivot = { seconds: expireSecs }
@ -242,7 +243,7 @@ export default {
const expiresAt = datePivot(new Date(), expirePivot) const expiresAt = datePivot(new Date(), expirePivot)
const description = `Funding @${user.name} on stacker.news` const description = `Funding @${user.name} on stacker.news`
try { try {
const invoice = await createInvoice({ const invoice = await (hodlInvoice ? createHodlInvoice : createInvoice)({
description: user.hideInvoiceDesc ? undefined : description, description: user.hideInvoiceDesc ? undefined : description,
lnd, lnd,
tokens: amount, tokens: amount,
@ -254,6 +255,8 @@ export default {
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description},
${invLimit}::INTEGER, ${balanceLimit})`) ${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 // the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice // this makes sure that only the person who created this invoice
// has access to the HMAC // has access to the HMAC
@ -312,6 +315,23 @@ export default {
// take pr and createWithdrawl // take pr and createWithdrawl
return await createWithdrawal(parent, { invoice: res2.pr, maxFee }, { me, models, lnd }) 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
} }
}, },

View File

@ -26,15 +26,15 @@ export default gql`
bookmarkItem(id: ID): Item bookmarkItem(id: ID): Item
subscribeItem(id: ID): Item subscribeItem(id: ID): Item
deleteItem(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! 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], invoiceHash: String, invoiceHmac: 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!, boost: Int, forward: [ItemForwardInput]): 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, upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): 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], invoiceHash: String, invoiceHmac: 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, invoiceHash: String, invoiceHmac: String): Item! upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
dontLikeThis(id: ID!): Boolean! 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! pollVote(id: ID!): ID!
} }

View File

@ -9,9 +9,10 @@ export default gql`
} }
extend type Mutation { extend type Mutation {
createInvoice(amount: Int!, expireSecs: Int): Invoice! createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!): Withdrawl!
cancelInvoice(hash: String!, hmac: String!): Invoice!
} }
type Invoice { type Invoice {
@ -26,6 +27,7 @@ export default gql`
satsRequested: Int! satsRequested: Int!
nostr: JSONObject nostr: JSONObject
hmac: String hmac: String
isHeld: Boolean
} }
type Withdrawl { type Withdrawl {

View File

@ -9,7 +9,6 @@ import { bountySchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form' import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button' import CancelButton from './cancel-button'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form' import { normalizeForwards } from '../lib/form'
export function BountyForm ({ export function BountyForm ({
@ -36,6 +35,8 @@ export function BountyForm ({
$text: String $text: String
$boost: Int $boost: Int
$forward: [ItemForwardInput] $forward: [ItemForwardInput]
$hash: String
$hmac: String
) { ) {
upsertBounty( upsertBounty(
sub: $sub sub: $sub
@ -45,6 +46,8 @@ export function BountyForm ({
text: $text text: $text
boost: $boost boost: $boost
forward: $forward forward: $forward
hash: $hash
hmac: $hmac
) { ) {
id id
} }
@ -52,9 +55,8 @@ export function BountyForm ({
` `
) )
const submitUpsertBounty = useCallback( const onSubmit = useCallback(
// we ignore the invoice since only stackers can post bounties async ({ boost, bounty, ...values }) => {
async (_, boost, bounty, values, ...__) => {
const { error } = await upsertBounty({ const { error } = await upsertBounty({
variables: { variables: {
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
@ -75,9 +77,8 @@ export function BountyForm ({
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
}, [upsertBounty, router]) }, [upsertBounty, router]
)
const invoiceableUpsertBounty = useInvoiceable(submitUpsertBounty, { requireSession: true })
return ( return (
<Form <Form
@ -89,11 +90,10 @@ export function BountyForm ({
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}
invoiceable={{ requireSession: true }}
onSubmit={ onSubmit={
handleSubmit || handleSubmit ||
(async ({ boost, bounty, cost, ...values }) => { onSubmit
return invoiceableUpsertBounty(cost, boost, bounty, values)
})
} }
storageKeyPrefix={item ? undefined : 'bounty'} storageKeyPrefix={item ? undefined : 'bounty'}
> >

View File

@ -13,7 +13,6 @@ import { discussionSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form' import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button' import CancelButton from './cancel-button'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form' import { normalizeForwards } from '../lib/form'
export function DiscussionForm ({ export function DiscussionForm ({
@ -30,24 +29,22 @@ export function DiscussionForm ({
// const me = useMe() // const me = useMe()
const [upsertDiscussion] = useMutation( const [upsertDiscussion] = useMutation(
gql` gql`
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) { 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, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
id id
} }
}` }`
) )
const submitUpsertDiscussion = useCallback( const onSubmit = useCallback(
async (_, boost, values, invoiceHash, invoiceHmac) => { async ({ boost, ...values }) => {
const { error } = await upsertDiscussion({ const { error } = await upsertDiscussion({
variables: { variables: {
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
id: item?.id, id: item?.id,
boost: boost ? Number(boost) : undefined, boost: boost ? Number(boost) : undefined,
...values, ...values,
forward: normalizeForwards(values.forward), forward: normalizeForwards(values.forward)
invoiceHash,
invoiceHmac
} }
}) })
if (error) { if (error) {
@ -60,9 +57,8 @@ export function DiscussionForm ({
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
}, [upsertDiscussion, router]) }, [upsertDiscussion, router]
)
const invoiceableUpsertDiscussion = useInvoiceable(submitUpsertDiscussion)
const [getRelated, { data: relatedData }] = useLazyQuery(gql` const [getRelated, { data: relatedData }] = useLazyQuery(gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
@ -87,9 +83,8 @@ export function DiscussionForm ({
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}
onSubmit={handleSubmit || (async ({ boost, cost, ...values }) => { invoiceable
return invoiceableUpsertDiscussion(cost, boost, values) onSubmit={handleSubmit || onSubmit}
})}
storageKeyPrefix={item ? undefined : 'discussion'} storageKeyPrefix={item ? undefined : 'discussion'}
> >
{children} {children}

View File

@ -19,19 +19,24 @@ import { useLazyQuery } from '@apollo/client'
import { USER_SEARCH } from '../fragments/users' import { USER_SEARCH } from '../fragments/users'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast' import { useToast } from './toast'
import { useInvoiceable } from './invoice'
export function SubmitButton ({ 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 ( return (
<Button <Button
variant={variant || 'main'} variant={variant || 'main'}
type='submit' type='submit'
disabled={disabled || isSubmitting} disabled={disabled || formik.isSubmitting}
onClick={value onClick={value
? e => { ? e => {
setFieldValue('submit', value) formik.setFieldValue('submit', value)
onClick && onClick(e) onClick && onClick(e)
} }
: onClick} : onClick}
@ -470,7 +475,7 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
const StorageKeyPrefixContext = createContext() const StorageKeyPrefixContext = createContext()
export function Form ({ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, ...props initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, ...props
}) { }) {
const toaster = useToast() const toaster = useToast()
useEffect(() => { useEffect(() => {
@ -479,6 +484,16 @@ export function Form ({
} }
}, []) }, [])
// if `invoiceable` is set,
// support for payment per invoice if they are lurking or don't have enough balance
// is added to submit handlers.
// submit handlers need to accept { satsReceived, hash, hmac } in their first argument
// and use them as variables in their GraphQL mutation
if (invoiceable && onSubmit) {
const options = typeof invoiceable === 'object' ? invoiceable : undefined
onSubmit = useInvoiceable(onSubmit, options)
}
return ( return (
<Formik <Formik
initialValues={initial} initialValues={initial}

View File

@ -16,15 +16,16 @@ export default function FundError ({ onClose, amount, onPayment }) {
<Button variant='success' onClick={onClose}>fund wallet</Button> <Button variant='success' onClick={onClose}>fund wallet</Button>
</Link> </Link>
<span className='d-flex mx-3 fw-bold text-muted align-items-center'>or</span> <span className='d-flex mx-3 fw-bold text-muted align-items-center'>or</span>
<Button variant='success' onClick={() => createInvoice(amount).catch(err => setError(err.message || err))}>pay invoice</Button> <Button variant='success' onClick={() => createInvoice({ amount }).catch(err => setError(err.message || err))}>pay invoice</Button>
</div> </div>
</> </>
) )
} }
export const isInsufficientFundsError = (error) => { export const payOrLoginError = (error) => {
const matches = ['insufficient funds', 'you must be logged in or pay']
if (Array.isArray(error)) { 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))
} }

View File

@ -5,38 +5,39 @@ import { gql } from 'graphql-tag'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import Qr, { QrSkeleton } from './qr' import Qr, { QrSkeleton } from './qr'
import { CopyInput } from './form'
import { INVOICE } from '../fragments/wallet' import { INVOICE } from '../fragments/wallet'
import InvoiceStatus from './invoice-status' import InvoiceStatus from './invoice-status'
import { useMe } from './me' import { useMe } from './me'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { sleep } from '../lib/time' import { sleep } from '../lib/time'
import FundError, { isInsufficientFundsError } from './fund-error' import FundError, { payOrLoginError } from './fund-error'
import { usePaymentTokens } from './payment-tokens' 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 variant = 'default'
let status = 'waiting for you' let status = 'waiting for you'
let webLn = true let webLn = true
if (invoice.confirmedAt) { if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
variant = 'confirmed' variant = 'confirmed'
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}` status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
webLn = false webLn = false
} else if (expired) {
variant = 'failed'
status = 'expired'
webLn = false
} else if (invoice.cancelled) { } else if (invoice.cancelled) {
variant = 'failed' variant = 'failed'
status = 'cancelled' status = 'cancelled'
webLn = false webLn = false
} else if (invoice.expiresAt <= new Date()) {
variant = 'failed'
status = 'expired'
webLn = false
} }
useEffect(() => { useEffect(() => {
if (invoice.confirmedAt) { if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived)) {
onConfirmation?.(invoice) onPayment?.(invoice)
} }
}, [invoice.confirmedAt]) }, [invoice.confirmedAt, invoice.isHeld, invoice.satsReceived])
const { nostr } = invoice const { nostr } = invoice
@ -47,6 +48,13 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) {
description={numWithUnits(invoice.satsRequested, { abbreviate: false })} description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
statusVariant={variant} status={status} statusVariant={variant} status={status}
/> />
<div className='text-muted text-center'>
<Countdown
date={invoice.expiresAt} onComplete={() => {
setExpired(true)
}}
/>
</div>
<div className='w-100'> <div className='w-100'>
{nostr {nostr
? <AccordianItem ? <AccordianItem
@ -65,55 +73,18 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) {
) )
} }
const Contacts = ({ invoiceHash, invoiceHmac }) => { const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresAt, ...props }) => {
const subject = `Support request for payment hash: ${invoiceHash}`
const body = 'Hi, I successfully paid for <insert action> but the action did not work.'
return (
<div className='d-flex flex-column justify-content-center mt-2'>
<div className='w-100'>
<CopyInput
label={<>payment token <small className='text-danger fw-normal ms-2'>save this</small></>}
type='text' placeholder={invoiceHash + '|' + invoiceHmac} readOnly noForm
/>
</div>
<div className='d-flex flex-row justify-content-center'>
<a
href={`mailto:kk@stacker.news?subject=${subject}&body=${body}`} className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
e-mail
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://tribes.sphinx.chat/t/stackerzchat' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
sphinx
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://t.me/k00bideh' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
telegram
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE%3D%40smp10.simplex.im%2FebLYaEFGjsD3uK4fpE326c5QI1RZSxau%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAV086Oj5yCsavWzIbRMCVuF6jq793Tt__rWvCec__viI%253D%26srv%3Drb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22cZwSGoQhyOUulzp7rwCdWQ%3D%3D%22%7D' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
simplex
</a>
</div>
</div>
)
}
const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => {
const { data, loading, error } = useQuery(INVOICE, { const { data, loading, error } = useQuery(INVOICE, {
pollInterval: 1000, pollInterval: 1000,
variables: { id } variables: { id }
}) })
const [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
}
}
`)
if (error) { if (error) {
if (error.message?.includes('invoice not found')) { if (error.message?.includes('invoice not found')) {
return 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.' let errorStatus = 'Something went wrong trying to perform the action after payment.'
if (errorCount > 1) { 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 ( return (
<> <>
@ -137,8 +108,17 @@ const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => {
<div className='my-3'> <div className='my-3'>
<InvoiceStatus variant='failed' status={errorStatus} /> <InvoiceStatus variant='failed' status={errorStatus} />
</div> </div>
<div className='d-flex flex-row mt-3 justify-content-center'><Button variant='info' onClick={repeat}>Retry</Button></div> <div className='d-flex flex-row mt-3 justify-content-center'>
<Contacts invoiceHash={hash} invoiceHmac={hmac} /> <Button className='mx-1' variant='info' onClick={repeat}>Retry</Button>
<Button
className='mx-1'
variant='danger' onClick={async () => {
await cancelInvoice({ variables: { hash, hmac } })
onClose()
}}
>Cancel
</Button>
</div>
</> </>
) )
: null} : null}
@ -150,64 +130,67 @@ const defaultOptions = {
forceInvoice: false, forceInvoice: false,
requireSession: false requireSession: false
} }
export const useInvoiceable = (fn, options = defaultOptions) => { export const useInvoiceable = (onSubmit, options = defaultOptions) => {
const me = useMe() const me = useMe()
const [createInvoice, { data }] = useMutation(gql` const [createInvoice, { data }] = useMutation(gql`
mutation createInvoice($amount: Int!) { mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount, expireSecs: 1800) { createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) {
id id
hash hash
hmac hmac
expiresAt
} }
}`) }`)
const showModal = useShowModal() const showModal = useShowModal()
const [fnArgs, setFnArgs] = useState() const [formValues, setFormValues] = useState()
const { addPaymentToken, removePaymentToken } = usePaymentTokens() const [submitArgs, setSubmitArgs] = useState()
// fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice
let errorCount = 0 let errorCount = 0
const onConfirmation = useCallback( const onPayment = useCallback(
(onClose, hmac) => { (onClose, hmac) => {
return async ({ id, satsReceived, hash }) => { return async ({ id, satsReceived, expiresAt, hash }) => {
addPaymentToken(hash, hmac, satsReceived)
await sleep(500) await sleep(500)
const repeat = () => const repeat = () =>
fn(satsReceived, ...fnArgs, hash, hmac) // call onSubmit handler and pass invoice data
.then(() => { onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs)
removePaymentToken(hash, hmac)
})
.then(onClose) .then(onClose)
.catch((error) => { .catch((error) => {
// if error happened after payment, show repeat and cancel options
// by passing `errorCount` and `repeat`
console.error(error) console.error(error)
errorCount++ errorCount++
onClose() onClose()
showModal(onClose => ( showModal(onClose => (
<ActionInvoice <MutationInvoice
id={id} id={id}
hash={hash} hash={hash}
hmac={hmac} hmac={hmac}
onConfirmation={onConfirmation(onClose, hmac)} expiresAt={expiresAt}
onClose={onClose}
onPayment={onPayment(onClose, hmac)}
successVerb='received' successVerb='received'
errorCount={errorCount} errorCount={errorCount}
repeat={repeat} repeat={repeat}
/> />
), { keepOpen: true }) ), { keepOpen: true })
}) })
// prevents infinite loop of calling `onConfirmation` // prevents infinite loop of calling `onPayment`
if (errorCount === 0) await repeat() if (errorCount === 0) await repeat()
} }
}, [fn, fnArgs] }, [onSubmit, submitArgs]
) )
const invoice = data?.createInvoice const invoice = data?.createInvoice
useEffect(() => { useEffect(() => {
if (invoice) { if (invoice) {
showModal(onClose => ( showModal(onClose => (
<ActionInvoice <MutationInvoice
id={invoice.id} id={invoice.id}
hash={invoice.hash} hash={invoice.hash}
hmac={invoice.hmac} hmac={invoice.hmac}
onConfirmation={onConfirmation(onClose, invoice.hmac)} expiresAt={invoice.expiresAt}
onClose={onClose}
onPayment={onPayment(onClose, invoice.hmac)}
successVerb='received' successVerb='received'
/> />
), { keepOpen: true } ), { keepOpen: true }
@ -215,21 +198,31 @@ export const useInvoiceable = (fn, options = defaultOptions) => {
} }
}, [invoice?.id]) }, [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) { if (!me && options.requireSession) {
throw new Error('you must be logged in') 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 { try {
return await fn(amount, ...args) return await onSubmit(formValues, ...submitArgs)
} catch (error) { } catch (error) {
if (isInsufficientFundsError(error)) { if (payOrLoginError(error)) {
showModal(onClose => { showModal(onClose => {
return ( return (
<FundError <FundError
onClose={onClose} onClose={onClose}
amount={amount} amount={cost}
onPayment={async (_, invoiceHash, invoiceHmac) => { await fn(amount, ...args, invoiceHash, invoiceHmac) }} onPayment={async ({ satsReceived, hash, hmac }) => {
await onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs)
}}
/> />
) )
}) })
@ -238,12 +231,13 @@ export const useInvoiceable = (fn, options = defaultOptions) => {
throw error throw error
} }
} }
setFnArgs(args) setFormValues(formValues)
await createInvoice({ variables: { amount } }) setSubmitArgs(submitArgs)
await createInvoice({ variables: { amount: cost } })
// tell onSubmit handler that we want to keep local storage // tell onSubmit handler that we want to keep local storage
// even though the submit handler was "successful" // even though the submit handler was "successful"
return { keepLocalStorage: true } return { keepLocalStorage: true }
}, [fn, setFnArgs, createInvoice]) }, [onSubmit, setFormValues, setSubmitArgs, createInvoice])
return actionFn return onSubmitWrapper
} }

View File

@ -5,7 +5,6 @@ import { Form, Input, SubmitButton } from './form'
import { useMe } from './me' import { useMe } from './me'
import UpBolt from '../svgs/bolt.svg' import UpBolt from '../svgs/bolt.svg'
import { amountSchema } from '../lib/validate' import { amountSchema } from '../lib/validate'
import { useInvoiceable } from './invoice'
const defaultTips = [100, 1000, 10000, 100000] const defaultTips = [100, 1000, 10000, 100000]
@ -46,27 +45,24 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
inputRef.current?.focus() inputRef.current?.focus()
}, [onClose, itemId]) }, [onClose, itemId])
const submitAct = useCallback( const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
async (amount, invoiceHash, invoiceHmac) => { if (!me) {
if (!me) { const storageKey = `TIP-item:${itemId}`
const storageKey = `TIP-item:${itemId}` const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') window.localStorage.setItem(storageKey, existingAmount + amount)
window.localStorage.setItem(storageKey, existingAmount + amount) }
await act({
variables: {
id: itemId,
sats: Number(amount),
hash,
hmac
} }
await act({ })
variables: { await strike()
id: itemId, addCustomTip(Number(amount))
sats: Number(amount), onClose()
invoiceHash, }, [act])
invoiceHmac
}
})
await strike()
addCustomTip(Number(amount))
onClose()
}, [act, onClose, strike, itemId])
const invoiceableAct = useInvoiceable(submitAct)
return ( return (
<Form <Form
@ -75,9 +71,8 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
default: false default: false
}} }}
schema={amountSchema} schema={amountSchema}
onSubmit={async ({ amount }) => { invoiceable
return invoiceableAct(amount) onSubmit={onSubmit}
}}
> >
<Input <Input
label='amount' label='amount'

View File

@ -17,7 +17,6 @@ import Avatar from './avatar'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { jobSchema } from '../lib/validate' import { jobSchema } from '../lib/validate'
import CancelButton from './cancel-button' import CancelButton from './cancel-button'
import { useInvoiceable } from './invoice'
function satsMin2Mo (minute) { function satsMin2Mo (minute) {
return minute * 30 * 24 * 60 return minute * 30 * 24 * 60
@ -42,18 +41,17 @@ export default function JobForm ({ item, sub }) {
const [logoId, setLogoId] = useState(item?.uploadId) const [logoId, setLogoId] = useState(item?.uploadId)
const [upsertJob] = useMutation(gql` const [upsertJob] = useMutation(gql`
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String, mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String,
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) { $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int, $hash: String, $hmac: String) {
upsertJob(sub: $sub, id: $id, title: $title, company: $company, upsertJob(sub: $sub, id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text, location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status, logo: $logo) { url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) {
id id
} }
}` }`
) )
const submitUpsertJob = useCallback( const onSubmit = useCallback(
// we ignore the invoice since only stackers can post jobs async ({ maxBid, start, stop, ...values }) => {
async (_, maxBid, stop, start, values, ...__) => {
let status let status
if (start) { if (start) {
status = 'ACTIVE' status = 'ACTIVE'
@ -80,9 +78,8 @@ export default function JobForm ({ item, sub }) {
} else { } else {
await router.push(`/~${sub.name}/recent`) await router.push(`/~${sub.name}/recent`)
} }
}, [upsertJob, router, item?.id, sub?.name, logoId]) }, [upsertJob, router]
)
const invoiceableUpsertJob = useInvoiceable(submitUpsertJob, { requireSession: true })
return ( return (
<> <>
@ -101,9 +98,8 @@ export default function JobForm ({ item, sub }) {
}} }}
schema={jobSchema} schema={jobSchema}
storageKeyPrefix={storageKeyPrefix} storageKeyPrefix={storageKeyPrefix}
onSubmit={(async ({ maxBid, stop, start, ...values }) => { invoiceable={{ requireSession: true }}
return invoiceableUpsertJob(1000, maxBid, stop, start, values) onSubmit={onSubmit}
})}
> >
<div className='form-group'> <div className='form-group'>
<label className='form-label'>logo</label> <label className='form-label'>logo</label>
@ -167,7 +163,7 @@ export default function JobForm ({ item, sub }) {
) )
: ( : (
<ActionTooltip overlayText='1000 sats'> <ActionTooltip overlayText='1000 sats'>
<SubmitButton variant='secondary'>post <small> 1000 sats</small></SubmitButton> <SubmitButton cost={1000} variant='secondary'>post <small> 1000 sats</small></SubmitButton>
</ActionTooltip> </ActionTooltip>
)} )}
</div> </div>

View File

@ -14,7 +14,6 @@ import { linkSchema } from '../lib/validate'
import Moon from '../svgs/moon-fill.svg' import Moon from '../svgs/moon-fill.svg'
import { SubSelectInitial } from './sub-select-form' import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button' import CancelButton from './cancel-button'
import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form' import { normalizeForwards } from '../lib/form'
export function LinkForm ({ item, sub, editThreshold, children }) { export function LinkForm ({ item, sub, editThreshold, children }) {
@ -68,23 +67,21 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const [upsertLink] = useMutation( const [upsertLink] = useMutation(
gql` gql`
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) { 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, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
id id
} }
}` }`
) )
const submitUpsertLink = useCallback( const onSubmit = useCallback(
async (_, boost, title, values, invoiceHash, invoiceHmac) => { async ({ boost, title, ...values }) => {
const { error } = await upsertLink({ const { error } = await upsertLink({
variables: { variables: {
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
id: item?.id, id: item?.id,
boost: boost ? Number(boost) : undefined, boost: boost ? Number(boost) : undefined,
title: title.trim(), title: title.trim(),
invoiceHash,
invoiceHmac,
...values, ...values,
forward: normalizeForwards(values.forward) forward: normalizeForwards(values.forward)
} }
@ -98,9 +95,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
}, [upsertLink, router]) }, [upsertLink, router]
)
const invoiceableUpsertLink = useInvoiceable(submitUpsertLink)
useEffect(() => { useEffect(() => {
if (data?.pageTitleAndUnshorted?.title) { if (data?.pageTitleAndUnshorted?.title) {
@ -128,9 +124,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}
onSubmit={async ({ boost, title, cost, ...values }) => { invoiceable
return invoiceableUpsertLink(cost, boost, title, values) onSubmit={onSubmit}
}}
storageKeyPrefix={item ? undefined : 'link'} storageKeyPrefix={item ? undefined : 'link'}
> >
{children} {children}

View File

@ -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 (
<PaymentTokenContext.Provider value={{ tokens, addPaymentToken, removePaymentToken }}>
{children}
</PaymentTokenContext.Provider>
)
}
export function usePaymentTokens () {
return useContext(PaymentTokenContext)
}

View File

@ -11,7 +11,6 @@ import { pollSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form' import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button' import CancelButton from './cancel-button'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form' import { normalizeForwards } from '../lib/form'
export function PollForm ({ item, sub, editThreshold, children }) { export function PollForm ({ item, sub, editThreshold, children }) {
@ -22,16 +21,16 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const [upsertPoll] = useMutation( const [upsertPoll] = useMutation(
gql` gql`
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, 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, 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 id
} }
}` }`
) )
const submitUpsertPoll = useCallback( const onSubmit = useCallback(
async (_, boost, title, options, values, invoiceHash, invoiceHmac) => { async ({ boost, title, options, ...values }) => {
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { error } = await upsertPoll({ const { error } = await upsertPoll({
variables: { variables: {
@ -41,9 +40,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
title: title.trim(), title: title.trim(),
options: optionsFiltered, options: optionsFiltered,
...values, ...values,
forward: normalizeForwards(values.forward), forward: normalizeForwards(values.forward)
invoiceHash,
invoiceHmac
} }
}) })
if (error) { if (error) {
@ -55,9 +52,8 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
}, [upsertPoll, router]) }, [upsertPoll, router]
)
const invoiceableUpsertPoll = useInvoiceable(submitUpsertPoll)
const initialOptions = item?.poll?.options.map(i => i.option) 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 }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}
onSubmit={async ({ boost, title, options, cost, ...values }) => { invoiceable
return invoiceableUpsertPoll(cost, boost, title, options, values) onSubmit={onSubmit}
}}
storageKeyPrefix={item ? undefined : 'poll'} storageKeyPrefix={item ? undefined : 'poll'}
> >
{children} {children}

View File

@ -9,7 +9,6 @@ import FeeButton from './fee-button'
import { commentsViewedAfterComment } from '../lib/new-comments' import { commentsViewedAfterComment } from '../lib/new-comments'
import { commentSchema } from '../lib/validate' import { commentSchema } from '../lib/validate'
import Info from './info' import Info from './info'
import { useInvoiceable } from './invoice'
export function ReplyOnAnotherPage ({ parentId }) { export function ReplyOnAnotherPage ({ parentId }) {
return ( return (
@ -46,8 +45,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
const [upsertComment] = useMutation( const [upsertComment] = useMutation(
gql` gql`
${COMMENTS} ${COMMENTS}
mutation upsertComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) { mutation upsertComment($text: String!, $parentId: ID!, $hash: String, $hmac: String) {
upsertComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) {
...CommentFields ...CommentFields
comments { comments {
...CommentsRecursive ...CommentsRecursive
@ -91,17 +90,11 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
} }
) )
const submitComment = useCallback( const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => {
async (_, values, parentId, resetForm, invoiceHash, invoiceHmac) => { await upsertComment({ variables: { parentId, hash, hmac, ...values } })
const { error } = await upsertComment({ variables: { ...values, parentId, invoiceHash, invoiceHmac } }) resetForm({ text: '' })
if (error) { setReply(replyOpen || false)
throw new Error({ message: error.toString() }) }, [upsertComment, setReply])
}
resetForm({ text: '' })
setReply(replyOpen || false)
}, [upsertComment, setReply])
const invoiceableCreateComment = useInvoiceable(submitComment)
const replyInput = useRef(null) const replyInput = useRef(null)
useEffect(() => { useEffect(() => {
@ -129,9 +122,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
text: '' text: ''
}} }}
schema={commentSchema} schema={commentSchema}
onSubmit={async ({ cost, ...values }, { resetForm }) => { invoiceable
return invoiceableCreateComment(cost, values, parentId, resetForm) onSubmit={onSubmit}
}}
storageKeyPrefix={'reply-' + parentId} storageKeyPrefix={'reply-' + parentId}
> >
<MarkdownInput <MarkdownInput

View File

@ -1,7 +1,7 @@
import UpBolt from '../svgs/bolt.svg' import UpBolt from '../svgs/bolt.svg'
import styles from './upvote.module.css' import styles from './upvote.module.css'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import FundError, { isInsufficientFundsError } from './fund-error' import FundError, { payOrLoginError } from './fund-error'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import ItemAct from './item-act' import ItemAct from './item-act'
import { useMe } from './me' import { useMe } from './me'
@ -110,8 +110,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
const [act] = useMutation( const [act] = useMutation(
gql` gql`
mutation act($id: ID!, $sats: Int!, $invoiceHash: String, $invoiceHmac: String) { mutation act($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { act(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
sats sats
} }
}`, { }`, {
@ -177,14 +177,14 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
} }
}) })
} catch (error) { } catch (error) {
if (isInsufficientFundsError(error)) { if (payOrLoginError(error)) {
showModal(onClose => { showModal(onClose => {
return ( return (
<FundError <FundError
onClose={onClose} onClose={onClose}
amount={pendingSats} amount={pendingSats}
onPayment={async (_, invoiceHash) => { onPayment={async ({ hash, hmac }) => {
await act({ variables: { ...variables, invoiceHash } }) await act({ variables: { ...variables, hash, hmac } })
strike() strike()
}} }}
/> />

View File

@ -13,6 +13,7 @@ export const INVOICE = gql`
confirmedAt confirmedAt
expiresAt expiresAt
nostr nostr
isHeld
} }
}` }`

View File

@ -15,7 +15,6 @@ import { ServiceWorkerProvider } from '../components/serviceworker'
import { SSR } from '../lib/constants' import { SSR } from '../lib/constants'
import NProgress from 'nprogress' import NProgress from 'nprogress'
import 'nprogress/nprogress.css' import 'nprogress/nprogress.css'
import { PaymentTokenProvider } from '../components/payment-tokens'
NProgress.configure({ NProgress.configure({
showSpinner: false showSpinner: false
@ -92,11 +91,9 @@ function MyApp ({ Component, pageProps: { ...props } }) {
<PriceProvider price={price}> <PriceProvider price={price}>
<LightningProvider> <LightningProvider>
<ToastProvider> <ToastProvider>
<PaymentTokenProvider> <ShowModalProvider>
<ShowModalProvider> <Component ssrData={ssrData} {...otherProps} />
<Component ssrData={ssrData} {...otherProps} /> </ShowModalProvider>
</ShowModalProvider>
</PaymentTokenProvider>
</ToastProvider> </ToastProvider>
</LightningProvider> </LightningProvider>
</PriceProvider> </PriceProvider>

View File

@ -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");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "isHeld" BOOLEAN;

View File

@ -420,6 +420,8 @@ model Invoice {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int userId Int
hash String @unique(map: "Invoice.hash_unique") hash String @unique(map: "Invoice.hash_unique")
preimage String? @unique(map: "Invoice.preimage_unique")
isHeld Boolean?
bolt11 String bolt11 String
expiresAt DateTime expiresAt DateTime
confirmedAt DateTime? confirmedAt DateTime?

View File

@ -22,6 +22,9 @@ function nip57 ({ boss, lnd, models }) {
return return
} }
// check if invoice still exists since HODL invoices get deleted after usage
if (!inv) return
try { try {
// if parsing fails it's not a zap // if parsing fails it's not a zap
console.log('zapping', inv.desc) console.log('zapping', inv.desc)

View File

@ -1,12 +1,12 @@
const serialize = require('../api/resolvers/serial') 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 { datePivot } = require('../lib/time')
const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true } const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
// TODO this should all be done via websockets // TODO this should all be done via websockets
function checkInvoice ({ boss, models, lnd }) { function checkInvoice ({ boss, models, lnd }) {
return async function ({ data: { hash } }) { return async function ({ data: { hash, isHeldSet } }) {
let inv let inv
try { try {
inv = await getInvoice({ id: hash, lnd }) inv = await getInvoice({ id: hash, lnd })
@ -18,13 +18,20 @@ function checkInvoice ({ boss, models, lnd }) {
} }
console.log(inv) 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) { if (inv.is_confirmed) {
await serialize(models, await serialize(models,
models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`) models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`)
await boss.send('nip57', { hash }) return boss.send('nip57', { hash })
} else if (inv.is_canceled) { }
// mark as cancelled
await serialize(models, if (inv.is_canceled) {
return serialize(models,
models.invoice.update({ models.invoice.update({
where: { where: {
hash: inv.id hash: inv.id
@ -33,11 +40,27 @@ function checkInvoice ({ boss, models, lnd }) {
cancelled: true 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 // otherwise recheck in 60 seconds
const startAfter = new Date(inv.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60 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 })
} }
} }
} }