Replace FundError with InvoiceModal (#455)

* invoices are no longer deleted to prevent double-spends but marked as confirmed.
  therefore, during checkInvoice, we also check if the invoice is already confirmed.
* instead of showing FundError (with "fund wallet" and "pay invoice" as options), we now always immediately show an invoice
* since flagging, paying bounties and poll voting used FundError but only allowed spending from balance, they now also support paying per invoice

Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
ekzyis 2023-08-31 17:10:24 +02:00 committed by GitHub
parent ac45fdc234
commit 803acd1fc4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 112 additions and 84 deletions

View File

@ -62,6 +62,9 @@ async function checkInvoice (models, hash, hmac, fee) {
if (expired) {
throw new GraphQLError('invoice expired', { extensions: { code: 'BAD_INPUT' } })
}
if (invoice.confirmedAt) {
throw new GraphQLError('invoice already used', { extensions: { code: 'BAD_INPUT' } })
}
if (invoice.cancelled) {
throw new GraphQLError('invoice was canceled', { extensions: { code: 'BAD_INPUT' } })
@ -708,14 +711,25 @@ export default {
return rItem
}
},
pollVote: async (parent, { id }, { me, models }) => {
pollVote: async (parent, { id, hash, hmac }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
await serialize(models,
models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`,
Number(id), Number(me.id)))
let invoice
if (hash) {
invoice = await checkInvoice(models, hash, hmac)
}
const trx = [
models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id))
]
if (invoice) {
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } }))
}
await serialize(models, ...trx)
return id
},
@ -756,7 +770,7 @@ export default {
]
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 } }))
trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } }))
}
const query = await serialize(models, ...trx)
@ -810,12 +824,17 @@ export default {
sats
}
},
dontLikeThis: async (parent, { id }, { me, models }) => {
dontLikeThis: async (parent, { id, hash, hmac }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
let invoice
if (hash) {
invoice = await checkInvoice(models, hash, hmac, DONT_LIKE_THIS_COST)
}
// disallow self down votes
const [item] = await models.$queryRawUnsafe(`
${SELECT}
@ -825,8 +844,16 @@ export default {
throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } })
}
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER,
${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`)
const trx = [
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER,
${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`
]
if (invoice) {
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } }))
}
await serialize(models, ...trx)
return true
}
@ -1154,7 +1181,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
]
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 } }))
trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } }))
}
const query = await serialize(models, ...trx)

View File

@ -33,9 +33,9 @@ export default gql`
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!
dontLikeThis(id: ID!, hash: String, hmac: String): Boolean!
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
pollVote(id: ID!): ID!
pollVote(id: ID!, hash: String, hmac: String): ID!
}
type PollOption {

View File

@ -1,8 +1,9 @@
import { gql, useMutation } from '@apollo/client'
import Dropdown from 'react-bootstrap/Dropdown'
import FundError from './fund-error'
import { useShowModal } from './modal'
import { useToast } from './toast'
import { InvoiceModal, payOrLoginError } from './invoice'
import { DONT_LIKE_THIS_COST } from '../lib/constants'
export default function DontLikeThisDropdownItem ({ id }) {
const toaster = useToast()
@ -10,8 +11,8 @@ export default function DontLikeThisDropdownItem ({ id }) {
const [dontLikeThis] = useMutation(
gql`
mutation dontLikeThis($id: ID!) {
dontLikeThis(id: $id)
mutation dontLikeThis($id: ID!, $hash: String, $hmac: String) {
dontLikeThis(id: $id, hash: $hash, hmac: $hmac)
}`, {
update (cache) {
cache.modify({
@ -37,9 +38,17 @@ export default function DontLikeThisDropdownItem ({ id }) {
toaster.success('item flagged')
} catch (error) {
console.error(error)
if (error.toString().includes('insufficient funds')) {
if (payOrLoginError(error)) {
showModal(onClose => {
return <FundError onClose={onClose} />
return (
<InvoiceModal
amount={DONT_LIKE_THIS_COST}
onPayment={async ({ hash, hmac }) => {
await dontLikeThis({ variables: { id, hash, hmac } })
toaster.success('item flagged')
}}
/>
)
})
} else {
toaster.danger('failed to flag item')

View File

@ -1,31 +0,0 @@
import Link from 'next/link'
import Button from 'react-bootstrap/Button'
import { useInvoiceable } from './invoice'
import { Alert } from 'react-bootstrap'
import { useState } from 'react'
export default function FundError ({ onClose, amount, onPayment }) {
const [error, setError] = useState(null)
const createInvoice = useInvoiceable(onPayment, { forceInvoice: true })
return (
<>
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}
<p className='fw-bolder text-center'>you need more sats</p>
<div className='d-flex pb-3 pt-2 justify-content-center'>
<Link href='/wallet?type=fund'>
<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>
</div>
</>
)
}
export const payOrLoginError = (error) => {
const matches = ['insufficient funds', 'you must be logged in or pay']
if (Array.isArray(error)) {
return error.some(({ message }) => matches.some(m => message.includes(m)))
}
return matches.some(m => error.toString().includes(m))
}

View File

@ -10,10 +10,9 @@ import InvoiceStatus from './invoice-status'
import { useMe } from './me'
import { useShowModal } from './modal'
import { sleep } from '../lib/time'
import FundError, { payOrLoginError } from './fund-error'
import Countdown from './countdown'
export function Invoice ({ invoice, onPayment, successVerb }) {
export function Invoice ({ invoice, onPayment, info, successVerb }) {
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
let variant = 'default'
@ -55,6 +54,7 @@ export function Invoice ({ invoice, onPayment, successVerb }) {
}}
/>
</div>
{info && <div className='text-muted fst-italic text-center'>{info}</div>}
<div className='w-100'>
{nostr
? <AccordianItem
@ -74,6 +74,7 @@ export function Invoice ({ invoice, onPayment, successVerb }) {
}
const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresAt, ...props }) => {
const me = useMe()
const { data, loading, error } = useQuery(INVOICE, {
pollInterval: 1000,
variables: { id }
@ -99,9 +100,10 @@ const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresA
if (errorCount > 1) {
errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.'
}
const info = me ? 'Any additional received sats will fund your account' : null
return (
<>
<Invoice invoice={data.invoice} {...props} />
<Invoice invoice={data.invoice} info={info} {...props} />
{errorCount > 0
? (
<>
@ -128,7 +130,8 @@ const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresA
const defaultOptions = {
forceInvoice: false,
requireSession: false
requireSession: false,
replaceModal: false
}
export const useInvoiceable = (onSubmit, options = defaultOptions) => {
const me = useMe()
@ -193,7 +196,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
onPayment={onPayment(onClose, invoice.hmac)}
successVerb='received'
/>
), { keepOpen: true }
), { replaceModal: options.replaceModal, keepOpen: true }
)
}
}, [invoice?.id])
@ -214,21 +217,9 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
try {
return await onSubmit(formValues, ...submitArgs)
} catch (error) {
if (payOrLoginError(error)) {
showModal(onClose => {
return (
<FundError
onClose={onClose}
amount={cost}
onPayment={async ({ satsReceived, hash, hmac }) => {
await onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs)
}}
/>
)
})
return { keepLocalStorage: true }
if (!payOrLoginError(error)) {
throw error
}
throw error
}
}
setFormValues(formValues)
@ -241,3 +232,19 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
return onSubmitWrapper
}
export const InvoiceModal = ({ onPayment, amount }) => {
const createInvoice = useInvoiceable(onPayment, { replaceModal: true })
useEffect(() => {
createInvoice({ amount })
}, [])
}
export const payOrLoginError = (error) => {
const matches = ['insufficient funds', 'you must be logged in or pay']
if (Array.isArray(error)) {
return error.some(({ message }) => matches.some(m => message.includes(m)))
}
return matches.some(m => error.toString().includes(m))
}

View File

@ -59,7 +59,9 @@ export default function useModal () {
const showModal = useCallback(
(getContent, options) => {
if (modalContent) {
setModalStack(stack => ([...stack, modalContent]))
if (options?.replaceModal) {
setModalStack(stack => ([]))
} else setModalStack(stack => ([...stack, modalContent]))
}
setModalOptions(options)
setModalContent(getContent(onClose))

View File

@ -6,8 +6,8 @@ import { useMutation, gql } from '@apollo/client'
import { useMe } from './me'
import { numWithUnits } from '../lib/format'
import { useShowModal } from './modal'
import FundError from './fund-error'
import { useRoot } from './root'
import { InvoiceModal, payOrLoginError } from './invoice'
export default function PayBounty ({ children, item }) {
const me = useMe()
@ -16,8 +16,8 @@ export default function PayBounty ({ children, item }) {
const [act] = useMutation(
gql`
mutation act($id: ID!, $sats: Int!) {
act(id: $id, sats: $sats) {
mutation act($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
sats
}
}`, {
@ -73,9 +73,16 @@ export default function PayBounty ({ children, item }) {
})
onComplete()
} catch (error) {
if (error.toString().includes('insufficient funds')) {
if (payOrLoginError(error)) {
showModal(onClose => {
return <FundError onClose={onClose} />
return (
<InvoiceModal
amount={root.bounty}
onPayment={async ({ hash, hmac }) => {
await act({ variables: { id: item.id, sats: root.bounty, hash, hmac } })
}}
/>
)
})
return
}

View File

@ -8,15 +8,16 @@ import Check from '../svgs/checkbox-circle-fill.svg'
import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip'
import { useShowModal } from './modal'
import FundError from './fund-error'
import { POLL_COST } from '../lib/constants'
import { InvoiceModal } from './invoice'
export default function Poll ({ item }) {
const me = useMe()
const showModal = useShowModal()
const [pollVote] = useMutation(
gql`
mutation pollVote($id: ID!) {
pollVote(id: $id)
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
pollVote(id: $id, hash: $hash, hmac: $hmac)
}`, {
update (cache, { data: { pollVote } }) {
cache.modify({
@ -60,11 +61,16 @@ export default function Poll ({ item }) {
}
})
} catch (error) {
if (error.toString().includes('insufficient funds')) {
showModal(onClose => {
return <FundError onClose={onClose} />
})
}
showModal(onClose => {
return (
<InvoiceModal
amount={item.pollCost || POLL_COST}
onPayment={async ({ hash, hmac }) => {
await pollVote({ variables: { id: v.id, hash, hmac } })
}}
/>
)
})
}
}
: signIn}

View File

@ -1,7 +1,6 @@
import UpBolt from '../svgs/bolt.svg'
import styles from './upvote.module.css'
import { gql, useMutation } from '@apollo/client'
import FundError, { payOrLoginError } from './fund-error'
import ActionTooltip from './action-tooltip'
import ItemAct from './item-act'
import { useMe } from './me'
@ -13,6 +12,7 @@ import Popover from 'react-bootstrap/Popover'
import { useShowModal } from './modal'
import { LightningConsumer, useLightning } from './lightning'
import { numWithUnits } from '../lib/format'
import { InvoiceModal, payOrLoginError } from './invoice'
const getColor = (meSats) => {
if (!meSats || meSats <= 10) {
@ -180,8 +180,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
if (payOrLoginError(error)) {
showModal(onClose => {
return (
<FundError
onClose={onClose}
<InvoiceModal
amount={pendingSats}
onPayment={async ({ hash, hmac }) => {
await act({ variables: { ...variables, hash, hmac } })

View File

@ -24,7 +24,9 @@ function checkInvoice ({ boss, models, lnd }) {
const expired = new Date(inv.expires_at) <= new Date()
if (inv.is_confirmed) {
if (inv.is_confirmed && !inv.is_held) {
// never mark hodl invoices as confirmed here because
// we manually confirm them when we settle them
await serialize(models,
models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`)
return boss.send('nip57', { hash })