diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index b005f61d..f82edb3a 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -67,6 +67,7 @@ export interface WidgetProps { text?: string ButtonComponent?: React.ComponentType success?: boolean + pendingFinalization?: boolean successText?: string theme?: ThemeName | Theme foot?: React.ReactNode @@ -159,6 +160,7 @@ export const Widget: React.FunctionComponent = props => { to, foot, success = false, + pendingFinalization = false, paymentId, successText = 'Thank you!', disablePaymentId, @@ -221,6 +223,7 @@ export const Widget: React.FunctionComponent = props => { const qrLoading = loading || isWaitingForPaymentId + const showPaymentPendingSpinner = pendingFinalization && !success // websockets if standalone @@ -1254,7 +1257,7 @@ export const Widget: React.FunctionComponent = props => { - {qrLoading ? ( + {qrLoading || showPaymentPendingSpinner ? ( = props => { onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' && isDraftValid && !isSameAmount) { applyDraftAmount(); - } + } }} thousandSeparator allowLeadingZeros={false} @@ -1349,7 +1352,7 @@ export const Widget: React.FunctionComponent = props => { ) : null} - {success || hideSendButton ? null : ( + {success || showPaymentPendingSpinner || hideSendButton ? null : ( { // Use createElement to avoid JSX element-type incompatibility from duplicate React types diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index 54045b96..82049543 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -162,6 +162,7 @@ export const WidgetContainer: React.FunctionComponent = const [thisPrice, setThisPrice] = useState(0); const [usdPrice, setUsdPrice] = useState(0); const [success, setSuccess] = useState(false); + const [pendingFinalization, setPendingFinalization] = useState(false); const { enqueueSnackbar } = useSnackbar(); const [shiftCompleted, setShiftCompleted] = useState(false); @@ -194,6 +195,8 @@ export const WidgetContainer: React.FunctionComponent = const expectedAmount = currencyObj ? currencyObj?.float : undefined const receivedAmount = resolveNumber(transaction.amount); const currencyTicker = getCurrencyTypeFromAddress(to); + const isXec = currencyTicker === 'XEC'; + const isFinalized = transaction.txStatus === 'finalized'; if (shouldTriggerOnSuccess( transaction, @@ -206,6 +209,15 @@ export const WidgetContainer: React.FunctionComponent = opReturn, currencyObj )) { + if (isXec && !isFinalized) { + setPendingFinalization(true); + if (transaction.txStatus === 'mempool') { + onTransaction?.(transaction); + } + thisSetNewTxs([]); + return; + } + if (sound && !isPropsTrue(disableSound)) { txSound.play().catch(() => {}); } @@ -217,6 +229,7 @@ export const WidgetContainer: React.FunctionComponent = }Received ${receivedAmount} ${currencyTicker}`, snackbarOptionsSuccess, ); + setPendingFinalization(false); setSuccess(true); onSuccess?.(transaction); } else { @@ -258,7 +271,8 @@ export const WidgetContainer: React.FunctionComponent = thisPrice, currencyObj, randomSatoshis, - donationRate + donationRate, + pendingFinalization ], ); @@ -411,6 +425,7 @@ export const WidgetContainer: React.FunctionComponent = price={thisPrice} usdPrice={usdPrice} success={success} + pendingFinalization={pendingFinalization} disabled={disabled} editable={editable} newTxs={thisNewTxs} diff --git a/react/lib/tests/components/Widget.test.tsx b/react/lib/tests/components/Widget.test.tsx index 6b644b75..c0f12559 100644 --- a/react/lib/tests/components/Widget.test.tsx +++ b/react/lib/tests/components/Widget.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event' import { WidgetContainer as Widget } from '../../components/Widget/WidgetContainer' import { TEST_ADDRESSES } from '../util/constants' import copyToClipboard from 'copy-to-clipboard' -import type { Currency } from '../../util' +import type { Currency, Transaction } from '../../util' import { isFiat } from '../../util'; import config from '../../paybutton-config.json' @@ -524,3 +524,134 @@ describe('Widget – hideSendButton', () => { } ) }) + +describe('Widget – payment finalization states', () => { + test('XEC shows pending spinner on detection and runs success on finalization', async () => { + const onSuccess = jest.fn() + const onTransaction = jest.fn() + const setNewTxs = jest.fn() + + const xecMempoolTx: Transaction = { + hash: 'xec-mempool-hash', + amount: '1', + paymentId: '', + message: '', + rawMessage: '', + timestamp: 1, + address: TEST_ADDRESSES.ecash, + confirmed: false, + txStatus: 'mempool', + } + + const xecFinalizedTx: Transaction = { + ...xecMempoolTx, + hash: 'xec-finalized-hash', + confirmed: true, + txStatus: 'finalized', + } + + const { rerender } = render( + + ) + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).toBeNull() + }) + + rerender( + + ) + + await waitFor(() => { + expect(onTransaction).toHaveBeenCalled() + }) + expect(onSuccess).not.toHaveBeenCalled() + expect(screen.queryByText(/waiting for finalization/i)).toBeNull() + + rerender( + + ) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + await waitFor(() => { + expect(screen.getByText(/thank you!/i)).toBeTruthy() + }) + }) + + test('BCH keeps immediate success on first detected transaction', async () => { + const onSuccess = jest.fn() + const setNewTxs = jest.fn() + + const bchDetectedTx: Transaction = { + hash: 'bch-detected-hash', + amount: '1', + paymentId: '', + message: '', + rawMessage: '', + timestamp: 1, + address: TEST_ADDRESSES.bitcoincash, + confirmed: false, + txStatus: 'mempool', + } + + const { rerender } = render( + + ) + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).toBeNull() + }) + + rerender( + + ) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + expect(screen.queryByText(/waiting for finalization/i)).toBeNull() + }) +}) diff --git a/react/lib/util/chronik.ts b/react/lib/util/chronik.ts index c7fdc2a7..7fb6667c 100644 --- a/react/lib/util/chronik.ts +++ b/react/lib/util/chronik.ts @@ -157,6 +157,17 @@ const getTransactionFromChronikTransaction = async (transaction: Tx, address: st } } +const resolveTxStatusFromMsgType = (msgType: string): Transaction['txStatus'] | undefined => { + switch (msgType) { + case 'TX_ADDED_TO_MEMPOOL': + return 'mempool' + case 'TX_FINALIZED': + return 'finalized' + default: + return undefined + } +} + export const fromHash160 = (networkSlug: string, type: AddressType, hash160: string): string => { const buffer = Buffer.from(hash160, 'hex') @@ -243,10 +254,11 @@ export const parseWebsocketMessage = async ( const { msgType } = wsMsg; switch (msgType) { case 'TX_ADDED_TO_MEMPOOL': - case 'TX_CONFIRMED': { + case 'TX_FINALIZED': { const rawTransaction = await chronik.tx(wsMsg.txid); const transaction = await getTransactionFromChronikTransaction(rawTransaction, address ?? '') + transaction.txStatus = resolveTxStatusFromMsgType(msgType) setNewTx([transaction]); break; diff --git a/react/lib/util/types.ts b/react/lib/util/types.ts index 9ca41d6c..3d01a33e 100644 --- a/react/lib/util/types.ts +++ b/react/lib/util/types.ts @@ -12,6 +12,7 @@ export interface Transaction { amount: string paymentId: string confirmed?: boolean + txStatus?: 'mempool' | 'finalized' message: string timestamp: number address: string