bb2212d51e
This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN. Only the user which created the invoice knows the HMAC and thus can use the invoice hash.
238 lines
7.3 KiB
JavaScript
238 lines
7.3 KiB
JavaScript
import { useState, useCallback, useEffect } from 'react'
|
|
import { useMutation, useQuery } from '@apollo/client'
|
|
import { Button } from 'react-bootstrap'
|
|
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'
|
|
|
|
export function Invoice ({ invoice, onConfirmation, successVerb }) {
|
|
let variant = 'default'
|
|
let status = 'waiting for you'
|
|
let webLn = true
|
|
if (invoice.confirmedAt) {
|
|
variant = 'confirmed'
|
|
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
|
|
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)
|
|
}
|
|
}, [invoice.confirmedAt])
|
|
|
|
const { nostr } = invoice
|
|
|
|
return (
|
|
<>
|
|
<Qr webLn={webLn} value={invoice.bolt11} statusVariant={variant} status={status} />
|
|
<div className='w-100'>
|
|
{nostr
|
|
? <AccordianItem
|
|
header='Nostr Zap Request'
|
|
body={
|
|
<pre>
|
|
<code>
|
|
{JSON.stringify(nostr, null, 2)}
|
|
</code>
|
|
</pre>
|
|
}
|
|
/>
|
|
: null}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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'>
|
|
<span>Payment hash</span>
|
|
<div className='w-100'>
|
|
<CopyInput type='text' placeholder={invoiceHash} readOnly noForm />
|
|
</div>
|
|
<span>Payment HMAC</span>
|
|
<div className='w-100'>
|
|
<CopyInput type='text' placeholder={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, {
|
|
pollInterval: 1000,
|
|
variables: { id }
|
|
})
|
|
if (error) {
|
|
if (error.message?.includes('invoice not found')) {
|
|
return
|
|
}
|
|
return <div>error</div>
|
|
}
|
|
if (!data || loading) {
|
|
return <QrSkeleton status='loading' />
|
|
}
|
|
|
|
let errorStatus = 'Something went wrong. Please try again.'
|
|
if (errorCount > 1) {
|
|
errorStatus = 'Something still went wrong.\nPlease contact admins for support or to request a refund.'
|
|
}
|
|
return (
|
|
<>
|
|
<Invoice invoice={data.invoice} {...props} />
|
|
{errorCount > 0
|
|
? (
|
|
<>
|
|
<InvoiceStatus variant='failed' status={errorStatus} />
|
|
{errorCount === 1
|
|
? <div className='d-flex flex-row mt-3 justify-content-center'><Button variant='info' onClick={repeat}>Retry</Button></div>
|
|
: <Contacts invoiceHash={hash} invoiceHmac={hmac} />}
|
|
</>
|
|
)
|
|
: null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const defaultOptions = {
|
|
forceInvoice: false,
|
|
requireSession: false
|
|
}
|
|
export const useInvoiceable = (fn, options = defaultOptions) => {
|
|
const me = useMe()
|
|
const [createInvoice, { data }] = useMutation(gql`
|
|
mutation createInvoice($amount: Int!) {
|
|
createInvoice(amount: $amount) {
|
|
id
|
|
hash
|
|
hmac
|
|
}
|
|
}`)
|
|
const showModal = useShowModal()
|
|
const [fnArgs, setFnArgs] = useState()
|
|
|
|
// fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice
|
|
let errorCount = 0
|
|
const onConfirmation = useCallback(
|
|
(onClose, hmac) => {
|
|
return async ({ id, satsReceived, hash }) => {
|
|
await sleep(2000)
|
|
const repeat = () =>
|
|
fn(satsReceived, ...fnArgs, hash, hmac)
|
|
.then(onClose)
|
|
.catch((error) => {
|
|
console.error(error)
|
|
errorCount++
|
|
onClose()
|
|
showModal(onClose => (
|
|
<ActionInvoice
|
|
id={id}
|
|
hash={hash}
|
|
hmac={hmac}
|
|
onConfirmation={onConfirmation(onClose, hmac)}
|
|
successVerb='received'
|
|
errorCount={errorCount}
|
|
repeat={repeat}
|
|
/>
|
|
), { keepOpen: true })
|
|
})
|
|
// prevents infinite loop of calling `onConfirmation`
|
|
if (errorCount === 0) await repeat()
|
|
}
|
|
}, [fn, fnArgs]
|
|
)
|
|
|
|
const invoice = data?.createInvoice
|
|
useEffect(() => {
|
|
if (invoice) {
|
|
showModal(onClose => (
|
|
<ActionInvoice
|
|
id={invoice.id}
|
|
hash={invoice.hash}
|
|
hmac={invoice.hmac}
|
|
onConfirmation={onConfirmation(onClose, invoice.hmac)}
|
|
successVerb='received'
|
|
/>
|
|
), { keepOpen: true }
|
|
)
|
|
}
|
|
}, [invoice?.id])
|
|
|
|
const actionFn = useCallback(async (amount, ...args) => {
|
|
if (!me && options.requireSession) {
|
|
throw new Error('you must be logged in')
|
|
}
|
|
if (me && !options.forceInvoice) {
|
|
try {
|
|
return await fn(amount, ...args)
|
|
} catch (error) {
|
|
if (isInsufficientFundsError(error)) {
|
|
showModal(onClose => {
|
|
return (
|
|
<FundError
|
|
onClose={onClose}
|
|
amount={amount}
|
|
onPayment={async (_, invoiceHash, invoiceHmac) => { await fn(amount, ...args, invoiceHash, invoiceHmac) }}
|
|
/>
|
|
)
|
|
})
|
|
return
|
|
}
|
|
throw new Error({ message: error.toString() })
|
|
}
|
|
}
|
|
setFnArgs(args)
|
|
return createInvoice({ variables: { amount } })
|
|
}, [fn, setFnArgs, createInvoice])
|
|
|
|
return actionFn
|
|
}
|