import { useState, useCallback, useEffect } from 'react' import { useApolloClient, 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 { INVOICE } from '../fragments/wallet' import InvoiceStatus from './invoice-status' import { useMe } from './me' import { useShowModal } from './modal' import Countdown from './countdown' import PayerData from './payer-data' import Bolt11Info from './bolt11-info' import { useWebLN } from './webln' export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }) { const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date()) // if webLn was not passed, use true by default if (webLn === undefined) webLn = true let variant = 'default' let status = 'waiting for you' if (invoice.cancelled) { variant = 'failed' status = 'cancelled' webLn = false } else 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 } useEffect(() => { if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived)) { onPayment?.(invoice) } }, [invoice.confirmedAt, invoice.isHeld, invoice.satsReceived]) const { nostr, comment, lud18Data, bolt11, confirmedPreimage } = invoice return ( <> {!invoice.confirmedAt &&
{ setExpired(true) }} />
} {!modal && <> {info &&
{info}
}
{nostr ? {JSON.stringify(nostr, null, 2)} } /> : null}
{lud18Data &&
} />
} {comment &&
{comment}} />
} } ) } const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCancel, onRetry }) => { const { data, loading, error } = useQuery(INVOICE, { pollInterval: 1000, variables: { id } }) const [retryError, setRetryError] = useState(0) if (error) { if (error.message?.includes('invoice not found')) { return } return
error
} if (!data || loading) { return } const retry = !!onRetry let errorStatus = 'Something went wrong trying to perform the action after payment.' if (retryError > 0) { errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.' } return ( <> {retry ? ( <>
) : null} ) } const defaultOptions = { requireSession: false, forceInvoice: false } export const useInvoiceable = (onSubmit, options = defaultOptions) => { const me = useMe() const [createInvoice] = useMutation(gql` mutation createInvoice($amount: Int!) { createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) { id bolt11 hash hmac expiresAt } }`) const [cancelInvoice] = useMutation(gql` mutation cancelInvoice($hash: String!, $hmac: String!) { cancelInvoice(hash: $hash, hmac: $hmac) { id } } `) const showModal = useShowModal() const provider = useWebLN() const client = useApolloClient() const pollInvoice = (id) => client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } }) const onSubmitWrapper = useCallback(async ( { cost, ...formValues }, { variables, optimisticResponse, update, flowId, ...submitArgs }) => { // some actions require a session if (!me && options.requireSession) { throw new Error('you must be logged in') } // id for toast flows if (!flowId) flowId = (+new Date()).toString(16) // educated guesses where action might pass in the invoice amount // (field 'cost' has highest precedence) cost ??= formValues.amount // attempt action for the first time if (!cost || (me && !options.forceInvoice)) { try { const insufficientFunds = me?.privates.sats < cost return await onSubmit(formValues, { ...submitArgs, flowId, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse, update }) } catch (error) { if (!payOrLoginError(error) || !cost) { // can't handle error here - bail throw error } } } // initial attempt of action failed. we will create an invoice, pay and retry now. const { data, error } = await createInvoice({ variables: { amount: cost } }) if (error) { throw error } const inv = data.createInvoice // If this is a zap, we need to manually be optimistic to have a consistent // UX across custodial and WebLN zaps since WebLN zaps don't call GraphQL // mutations which implement optimistic responses natively. // Therefore, we check if this is a zap and then wrap the WebLN payment logic // with manual cache update calls. const itemId = optimisticResponse?.act?.id const isZap = !!itemId let _update if (isZap && update) { _update = () => { const fragment = { id: `Item:${itemId}`, fragment: gql` fragment ItemMeSats on Item { sats meSats } ` } const item = client.cache.readFragment(fragment) update(client.cache, { data: optimisticResponse }) // undo function return () => client.cache.writeFragment({ ...fragment, data: item }) } } // wait until invoice is paid or modal is closed const { modalOnClose, webLn, gqlCacheUpdateUndo } = await waitForPayment({ invoice: inv, showModal, provider, pollInvoice, gqlCacheUpdate: _update, flowId }) const retry = () => onSubmit( { hash: inv.hash, hmac: inv.hmac, expiresAt: inv.expiresAt, ...formValues }, // unset update function since we already ran an cache update if we paid using WebLN // also unset update function if null was explicitly passed in { ...submitArgs, variables, update: webLn ? null : undefined }) // first retry try { const ret = await retry() modalOnClose?.() return ret } catch (error) { gqlCacheUpdateUndo?.() console.error('retry error:', error) } // retry until success or cancel return await new Promise((resolve, reject) => { const cancelAndReject = async () => { await cancelInvoice({ variables: { hash: inv.hash, hmac: inv.hmac } }) reject(new Error('invoice canceled')) } showModal(onClose => { return ( { await cancelAndReject() onClose() }} onRetry={async () => { resolve(await retry()) onClose() }} /> ) }, { keepOpen: true, onClose: cancelAndReject }) }) }, [onSubmit, provider, createInvoice, !!me]) return onSubmitWrapper } const INVOICE_CANCELED_ERROR = 'invoice canceled' const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate, flowId }) => { if (provider.enabled) { try { return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId }) } catch (err) { // check for errors which mean that QR code will also fail if (err.message === INVOICE_CANCELED_ERROR) { throw err } } } // QR code as fallback return await new Promise((resolve, reject) => { showModal(onClose => { return ( resolve({ modalOnClose: onClose })} /> ) }, { keepOpen: true, onClose: reject }) }) } const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId }) => { let undoUpdate try { // try WebLN provider first return await new Promise((resolve, reject) => { // be optimistic and pretend zap was already successful for consistent zapping UX undoUpdate = gqlCacheUpdate?.() // can't use await here since we might be paying JIT invoices // and sendPaymentAsync is not supported yet. // see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync provider.sendPayment({ ...invoice, flowId }) // WebLN payment will never resolve here for JIT invoices // since they only get resolved after settlement which can't happen here .then(() => resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate })) .catch(err => { clearInterval(interval) reject(err) }) const interval = setInterval(async () => { try { const { data, error } = await pollInvoice(invoice.id) if (error) { clearInterval(interval) return reject(error) } const { invoice: inv } = data if (inv.isHeld && inv.satsReceived) { clearInterval(interval) resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate }) } if (inv.cancelled) { clearInterval(interval) reject(new Error(INVOICE_CANCELED_ERROR)) } } catch (err) { clearInterval(interval) reject(err) } }, 1000) }) } catch (err) { undoUpdate?.() console.error('WebLN payment failed:', err) throw err } } export const useInvoiceModal = (onPayment, deps) => { const onPaymentMemo = useCallback(onPayment, deps) return useInvoiceable(onPaymentMemo, { replaceModal: true }) } 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)) }