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:
parent
c6dfd1e39c
commit
ac45fdc234
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 (
|
||||
<Form
|
||||
|
@ -89,11 +90,10 @@ export function BountyForm ({
|
|||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
}}
|
||||
schema={schema}
|
||||
invoiceable={{ requireSession: true }}
|
||||
onSubmit={
|
||||
handleSubmit ||
|
||||
(async ({ boost, bounty, cost, ...values }) => {
|
||||
return invoiceableUpsertBounty(cost, boost, bounty, values)
|
||||
})
|
||||
onSubmit
|
||||
}
|
||||
storageKeyPrefix={item ? undefined : 'bounty'}
|
||||
>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 (
|
||||
<Button
|
||||
variant={variant || 'main'}
|
||||
type='submit'
|
||||
disabled={disabled || isSubmitting}
|
||||
disabled={disabled || formik.isSubmitting}
|
||||
onClick={value
|
||||
? e => {
|
||||
setFieldValue('submit', value)
|
||||
formik.setFieldValue('submit', value)
|
||||
onClick && onClick(e)
|
||||
}
|
||||
: onClick}
|
||||
|
@ -470,7 +475,7 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
|
|||
const StorageKeyPrefixContext = createContext()
|
||||
|
||||
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()
|
||||
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 (
|
||||
<Formik
|
||||
initialValues={initial}
|
||||
|
|
|
@ -16,15 +16,16 @@ export default function FundError ({ onClose, amount, onPayment }) {
|
|||
<Button variant='success' onClick={onClose}>fund wallet</Button>
|
||||
</Link>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
|
|
@ -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 (expired) {
|
||||
variant = 'failed'
|
||||
status = 'expired'
|
||||
webLn = false
|
||||
} else if (invoice.cancelled) {
|
||||
variant = 'failed'
|
||||
status = 'cancelled'
|
||||
webLn = false
|
||||
} else if (invoice.expiresAt <= new Date()) {
|
||||
variant = 'failed'
|
||||
status = 'expired'
|
||||
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}
|
||||
/>
|
||||
<div className='text-muted text-center'>
|
||||
<Countdown
|
||||
date={invoice.expiresAt} onComplete={() => {
|
||||
setExpired(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-100'>
|
||||
{nostr
|
||||
? <AccordianItem
|
||||
|
@ -65,55 +73,18 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) {
|
|||
)
|
||||
}
|
||||
|
||||
const Contacts = ({ invoiceHash, invoiceHmac }) => {
|
||||
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 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 }) => {
|
|||
<div className='my-3'>
|
||||
<InvoiceStatus variant='failed' status={errorStatus} />
|
||||
</div>
|
||||
<div className='d-flex flex-row mt-3 justify-content-center'><Button variant='info' onClick={repeat}>Retry</Button></div>
|
||||
<Contacts invoiceHash={hash} invoiceHmac={hmac} />
|
||||
<div className='d-flex flex-row mt-3 justify-content-center'>
|
||||
<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}
|
||||
|
@ -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 => (
|
||||
<ActionInvoice
|
||||
<MutationInvoice
|
||||
id={id}
|
||||
hash={hash}
|
||||
hmac={hmac}
|
||||
onConfirmation={onConfirmation(onClose, hmac)}
|
||||
expiresAt={expiresAt}
|
||||
onClose={onClose}
|
||||
onPayment={onPayment(onClose, hmac)}
|
||||
successVerb='received'
|
||||
errorCount={errorCount}
|
||||
repeat={repeat}
|
||||
/>
|
||||
), { 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 => (
|
||||
<ActionInvoice
|
||||
<MutationInvoice
|
||||
id={invoice.id}
|
||||
hash={invoice.hash}
|
||||
hmac={invoice.hmac}
|
||||
onConfirmation={onConfirmation(onClose, invoice.hmac)}
|
||||
expiresAt={invoice.expiresAt}
|
||||
onClose={onClose}
|
||||
onPayment={onPayment(onClose, invoice.hmac)}
|
||||
successVerb='received'
|
||||
/>
|
||||
), { 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 (
|
||||
<FundError
|
||||
onClose={onClose}
|
||||
amount={amount}
|
||||
onPayment={async (_, invoiceHash, invoiceHmac) => { 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
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<Form
|
||||
|
@ -75,9 +71,8 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
|
|||
default: false
|
||||
}}
|
||||
schema={amountSchema}
|
||||
onSubmit={async ({ amount }) => {
|
||||
return invoiceableAct(amount)
|
||||
}}
|
||||
invoiceable
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
|
|
|
@ -17,7 +17,6 @@ import Avatar from './avatar'
|
|||
import ActionTooltip from './action-tooltip'
|
||||
import { jobSchema } from '../lib/validate'
|
||||
import CancelButton from './cancel-button'
|
||||
import { useInvoiceable } from './invoice'
|
||||
|
||||
function satsMin2Mo (minute) {
|
||||
return minute * 30 * 24 * 60
|
||||
|
@ -42,18 +41,17 @@ export default function JobForm ({ item, sub }) {
|
|||
const [logoId, setLogoId] = useState(item?.uploadId)
|
||||
const [upsertJob] = useMutation(gql`
|
||||
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,
|
||||
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
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
const submitUpsertJob = useCallback(
|
||||
// we ignore the invoice since only stackers can post jobs
|
||||
async (_, maxBid, stop, start, values, ...__) => {
|
||||
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}
|
||||
>
|
||||
<div className='form-group'>
|
||||
<label className='form-label'>logo</label>
|
||||
|
@ -167,7 +163,7 @@ export default function JobForm ({ item, sub }) {
|
|||
)
|
||||
: (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
<MarkdownInput
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import UpBolt from '../svgs/bolt.svg'
|
||||
import styles from './upvote.module.css'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import FundError, { isInsufficientFundsError } from './fund-error'
|
||||
import FundError, { payOrLoginError } from './fund-error'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import ItemAct from './item-act'
|
||||
import { useMe } from './me'
|
||||
|
@ -110,8 +110,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||
|
||||
const [act] = useMutation(
|
||||
gql`
|
||||
mutation act($id: ID!, $sats: Int!, $invoiceHash: String, $invoiceHmac: String) {
|
||||
act(id: $id, sats: $sats, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
||||
mutation act($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
|
||||
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
|
||||
sats
|
||||
}
|
||||
}`, {
|
||||
|
@ -177,14 +177,14 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (isInsufficientFundsError(error)) {
|
||||
if (payOrLoginError(error)) {
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<FundError
|
||||
onClose={onClose}
|
||||
amount={pendingSats}
|
||||
onPayment={async (_, invoiceHash) => {
|
||||
await act({ variables: { ...variables, invoiceHash } })
|
||||
onPayment={async ({ hash, hmac }) => {
|
||||
await act({ variables: { ...variables, hash, hmac } })
|
||||
strike()
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -13,6 +13,7 @@ export const INVOICE = gql`
|
|||
confirmedAt
|
||||
expiresAt
|
||||
nostr
|
||||
isHeld
|
||||
}
|
||||
}`
|
||||
|
||||
|
|
|
@ -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 } }) {
|
|||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<PaymentTokenProvider>
|
||||
<ShowModalProvider>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
</ShowModalProvider>
|
||||
</PaymentTokenProvider>
|
||||
<ShowModalProvider>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
</ShowModalProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
|
|
|
@ -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");
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Invoice" ADD COLUMN "isHeld" BOOLEAN;
|
|
@ -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?
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue