Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions react/lib/components/Widget/Widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface WidgetProps {
text?: string
ButtonComponent?: React.ComponentType
success?: boolean
pendingFinalization?: boolean
successText?: string
theme?: ThemeName | Theme
foot?: React.ReactNode
Expand Down Expand Up @@ -159,6 +160,7 @@ export const Widget: React.FunctionComponent<WidgetProps> = props => {
to,
foot,
success = false,
pendingFinalization = false,
paymentId,
successText = 'Thank you!',
disablePaymentId,
Expand Down Expand Up @@ -221,6 +223,7 @@ export const Widget: React.FunctionComponent<WidgetProps> = props => {


const qrLoading = loading || isWaitingForPaymentId
const showPaymentPendingSpinner = pendingFinalization && !success


// websockets if standalone
Expand Down Expand Up @@ -1254,7 +1257,7 @@ export const Widget: React.FunctionComponent<WidgetProps> = props => {
</Box>
</Fade>

{qrLoading ? (
{qrLoading || showPaymentPendingSpinner ? (
<Box
position="absolute"
top={0}
Expand Down Expand Up @@ -1283,7 +1286,7 @@ export const Widget: React.FunctionComponent<WidgetProps> = props => {
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && isDraftValid && !isSameAmount) {
applyDraftAmount();
}
}
}}
thousandSeparator
allowLeadingZeros={false}
Expand Down Expand Up @@ -1349,7 +1352,7 @@ export const Widget: React.FunctionComponent<WidgetProps> = props => {
</Box>
) : null}

{success || hideSendButton ? null : (
{success || showPaymentPendingSpinner || hideSendButton ? null : (
<Box pt={2} flex={1} sx={classes.button_container}>
{
// Use createElement to avoid JSX element-type incompatibility from duplicate React types
Expand Down
17 changes: 16 additions & 1 deletion react/lib/components/Widget/WidgetContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export const WidgetContainer: React.FunctionComponent<WidgetContainerProps> =
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);
Expand Down Expand Up @@ -194,6 +195,8 @@ export const WidgetContainer: React.FunctionComponent<WidgetContainerProps> =
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,
Expand All @@ -206,6 +209,15 @@ export const WidgetContainer: React.FunctionComponent<WidgetContainerProps> =
opReturn,
currencyObj
)) {
if (isXec && !isFinalized) {
setPendingFinalization(true);
if (transaction.txStatus === 'mempool') {
onTransaction?.(transaction);
}
thisSetNewTxs([]);
return;
}

if (sound && !isPropsTrue(disableSound)) {
txSound.play().catch(() => {});
}
Expand All @@ -217,6 +229,7 @@ export const WidgetContainer: React.FunctionComponent<WidgetContainerProps> =
}Received ${receivedAmount} ${currencyTicker}`,
snackbarOptionsSuccess,
);
setPendingFinalization(false);
setSuccess(true);
onSuccess?.(transaction);
} else {
Expand Down Expand Up @@ -258,7 +271,8 @@ export const WidgetContainer: React.FunctionComponent<WidgetContainerProps> =
thisPrice,
currencyObj,
randomSatoshis,
donationRate
donationRate,
pendingFinalization
],
);

Expand Down Expand Up @@ -411,6 +425,7 @@ export const WidgetContainer: React.FunctionComponent<WidgetContainerProps> =
price={thisPrice}
usdPrice={usdPrice}
success={success}
pendingFinalization={pendingFinalization}
disabled={disabled}
editable={editable}
newTxs={thisNewTxs}
Expand Down
133 changes: 132 additions & 1 deletion react/lib/tests/components/Widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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(
<Widget
to={TEST_ADDRESSES.ecash}
currency={'XEC'}
disablePaymentId={true}
sound={false}
onSuccess={onSuccess}
onTransaction={onTransaction}
setNewTxs={setNewTxs}
/>
)

await waitFor(() => {
expect(screen.queryByText(/loading/i)).toBeNull()
})

rerender(
<Widget
to={TEST_ADDRESSES.ecash}
currency={'XEC'}
disablePaymentId={true}
sound={false}
onSuccess={onSuccess}
onTransaction={onTransaction}
setNewTxs={setNewTxs}
newTxs={[xecMempoolTx]}
/>
)

await waitFor(() => {
expect(onTransaction).toHaveBeenCalled()
})
expect(onSuccess).not.toHaveBeenCalled()
expect(screen.queryByText(/waiting for finalization/i)).toBeNull()

rerender(
<Widget
to={TEST_ADDRESSES.ecash}
currency={'XEC'}
disablePaymentId={true}
sound={false}
onSuccess={onSuccess}
onTransaction={onTransaction}
setNewTxs={setNewTxs}
newTxs={[xecFinalizedTx]}
/>
)

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(
<Widget
to={TEST_ADDRESSES.bitcoincash}
currency={'BCH'}
disablePaymentId={true}
sound={false}
onSuccess={onSuccess}
setNewTxs={setNewTxs}
/>
)

await waitFor(() => {
expect(screen.queryByText(/loading/i)).toBeNull()
})

rerender(
<Widget
to={TEST_ADDRESSES.bitcoincash}
currency={'BCH'}
disablePaymentId={true}
sound={false}
onSuccess={onSuccess}
setNewTxs={setNewTxs}
newTxs={[bchDetectedTx]}
/>
)

await waitFor(() => {
expect(onSuccess).toHaveBeenCalledTimes(1)
})
expect(screen.queryByText(/waiting for finalization/i)).toBeNull()
})
})
14 changes: 13 additions & 1 deletion react/lib/util/chronik.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions react/lib/util/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface Transaction {
amount: string
paymentId: string
confirmed?: boolean
txStatus?: 'mempool' | 'finalized'
message: string
timestamp: number
address: string
Expand Down