+
+ Swap coins with
+
+
+
+
{' '}
1 {selectedCoin.name} ~={' '}
@@ -879,7 +964,7 @@ export const AltpaymentWidget: React.FunctionComponent = props
{altpaymentEditable ? (
= props
>
{amountValidationMessage || '\u00A0'}
- {showManualAmountBackButton ? (
+ {showRateBackButton ? (
Back
@@ -936,7 +1021,7 @@ export const AltpaymentWidget: React.FunctionComponent = props
- {!preselectedCoin ? (
+ {!isPreselectedCoinAvailable ? (
Select a coin
= props => {
disablePaymentId,
goalAmount,
ButtonComponent = Button,
- currency = getCurrencyTypeFromAddress(to),
+ currency = getCurrencyTypeFromAddressOrDefault(to),
animation,
randomSatoshis = false,
editable = false,
@@ -295,7 +295,7 @@ export const Widget: React.FunctionComponent = props => {
(setAltpaymentError as ((e: AltpaymentError | undefined) => void) | undefined) ??
setInternalAltpaymentError
- const [internalAddressType, setInternalAddressType] = useState(getCurrencyTypeFromAddress(to))
+ const [internalAddressType, setInternalAddressType] = useState(getCurrencyTypeFromAddressOrDefault(to))
const thisAddressType = addressType ?? internalAddressType
const setThisAddressType =
(setAddressType as ((c: CryptoCurrency) => void) | undefined) ?? setInternalAddressType
@@ -573,7 +573,10 @@ export const Widget: React.FunctionComponent = props => {
useEffect(() => {
(async () => {
- if (isChild !== true) {
+ if (isChild === true) {
+ return
+ }
+ try {
await setupChronikWebSocket({
address: to,
txsSocket: thisTxsSocket,
@@ -582,20 +585,22 @@ export const Widget: React.FunctionComponent = props => {
setTxsSocket: setThisTxsSocket,
setNewTxs: setThisNewTxs,
})
- if (thisUseAltpayment) {
- await setupAltpaymentSocket({
- addressType: thisAddressType,
- wsBaseUrl,
- altpaymentSocket: thisAltpaymentSocket,
- setAltpaymentSocket: setThisAltpaymentSocket,
- setCoins: setThisCoins,
- setCoinPair: setThisCoinPair,
- setLoadingPair: setThisLoadingPair,
- setAltpaymentShift: setThisAltpaymentShift,
- setLoadingShift: setThisLoadingShift,
- setAltpaymentError: setThisAltpaymentError,
- })
- }
+ } catch (err) {
+ console.error('Error connecting to the blockchain websocket:', err)
+ }
+ if (thisUseAltpayment) {
+ await setupAltpaymentSocket({
+ addressType: thisAddressType,
+ wsBaseUrl,
+ altpaymentSocket: thisAltpaymentSocket,
+ setAltpaymentSocket: setThisAltpaymentSocket,
+ setCoins: setThisCoins,
+ setCoinPair: setThisCoinPair,
+ setLoadingPair: setThisLoadingPair,
+ setAltpaymentShift: setThisAltpaymentShift,
+ setLoadingShift: setThisLoadingShift,
+ setAltpaymentError: setThisAltpaymentError,
+ })
}
})()
return () => {
@@ -1116,6 +1121,22 @@ export const Widget: React.FunctionComponent = props => {
}
}
+ // The altpayment widget reasons in the settle coin (XEC/BCH), while the
+ // button amount is expressed in `currency`, which may be fiat. Converting
+ // here keeps both in sync; feeding a crypto amount into a fiat field made the
+ // amount grow on every round trip.
+ const updateAmountFromAltpayment = (settleAmount: string) => {
+ const settleFloat = +settleAmount
+ if (settleAmount === '' || Number.isNaN(settleFloat)) {
+ return
+ }
+ if (isFiat(currency) && price) {
+ updateAmount((settleFloat * price).toFixed(DECIMALS.FIAT))
+ } else {
+ updateAmount(settleAmount)
+ }
+ }
+
const qrCode = (
= props => {
=
const paymentClient = getAltpaymentClient()
- const addrType = getCurrencyTypeFromAddress(to);
+ const addrType = getCurrencyTypeFromAddressOrDefault(to);
if (
!isValidCurrency(currency) ||
(isCrypto(currency) && addrType !== currency)
@@ -193,7 +193,7 @@ export const WidgetContainer: React.FunctionComponent =
} else {
const expectedAmount = currencyObj ? currencyObj?.float : undefined
const receivedAmount = resolveNumber(transaction.amount);
- const currencyTicker = getCurrencyTypeFromAddress(to);
+ const currencyTicker = getCurrencyTypeFromAddressOrDefault(to);
if (shouldTriggerOnSuccess(
transaction,
@@ -289,7 +289,11 @@ export const WidgetContainer: React.FunctionComponent =
}
if (isGreaterThanZero(resolveNumber(tx.amount))) {
- handlePayment(tx);
+ // Never let a malformed transaction reject unhandled; it would show
+ // up as an uncaught error on the host page.
+ handlePayment(tx).catch(err => {
+ console.error('Error handling transaction:', err);
+ });
}
},
[handlePayment, success],
diff --git a/react/lib/tests/components/AltpaymentWidget.test.tsx b/react/lib/tests/components/AltpaymentWidget.test.tsx
index 5e00c3db..47537ce3 100644
--- a/react/lib/tests/components/AltpaymentWidget.test.tsx
+++ b/react/lib/tests/components/AltpaymentWidget.test.tsx
@@ -1,7 +1,7 @@
import { act } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { AltpaymentWidget } from '../../components/Widget/AltpaymentWidget'
+import { ALTPAYMENT_TIMEOUT_MS, AltpaymentWidget } from '../../components/Widget/AltpaymentWidget'
const altpaymentShift = {
depositAmount: '0.01',
@@ -163,3 +163,154 @@ describe('AltpaymentWidget copy feedback', () => {
expect(baseProps.setUseAltpayment).not.toHaveBeenCalled()
})
})
+
+const coinPair = {
+ min: '0.0001',
+ max: '10',
+ rate: '9500000000',
+ depositCoin: 'BTC',
+ settleCoin: 'XEC',
+ depositNetwork: 'bitcoin',
+ settleNetwork: 'mainnet',
+}
+
+describe('AltpaymentWidget preselected coin flow', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.clearAllTimers()
+ jest.useRealTimers()
+ jest.clearAllMocks()
+ cleanup()
+ })
+
+ test('non-editable buttons wait on the loading screen until the shift is ready', () => {
+ render()
+
+ expect(screen.getByText('Loading SideShift...')).toBeTruthy()
+ })
+
+ test('editable buttons show the amount form instead of an endless loading screen', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByText('Loading SideShift...')).toBeNull()
+ expect(screen.getByLabelText('Amount (BTC)')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Send Bitcoin' })).toBeTruthy()
+ })
+
+ test('editable buttons prefill the amount converted from the button amount', () => {
+ render(
+ ,
+ )
+
+ expect((screen.getByLabelText('Amount (BTC)') as HTMLInputElement).value).toBe('0.0001')
+ })
+
+ test('an unknown preselected coin falls back to the coin selector', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByText('Loading SideShift...')).toBeNull()
+ expect(screen.getAllByText('Select a coin').length).toBeGreaterThan(0)
+ })
+
+ test('gives up with an error when SideShift never sends the coin list', () => {
+ render()
+
+ expect(screen.getByText('Loading SideShift...')).toBeTruthy()
+ expect(baseProps.setAltpaymentError).not.toHaveBeenCalled()
+
+ act(() => {
+ jest.advanceTimersByTime(ALTPAYMENT_TIMEOUT_MS)
+ })
+
+ expect(baseProps.setAltpaymentError).toHaveBeenCalledWith({
+ errorType: 'connection-error',
+ errorMessage: 'Could not reach SideShift. Please try again.',
+ })
+ })
+
+ test('does not time out once the shift is ready', () => {
+ render(
+ ,
+ )
+
+ act(() => {
+ jest.advanceTimersByTime(ALTPAYMENT_TIMEOUT_MS * 2)
+ })
+
+ expect(baseProps.setAltpaymentError).not.toHaveBeenCalled()
+ })
+})
+
+describe('AltpaymentWidget editable amount', () => {
+ const socket = { emit: jest.fn() }
+ const editableProps = {
+ ...baseProps,
+ altpaymentEditable: true,
+ coinPair: coinPair as any,
+ altpaymentSocket: socket as any,
+ }
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ cleanup()
+ })
+
+ test('quotes the amount the user typed, not the one derived from the button', () => {
+ render()
+
+ const input = screen.getByLabelText('Amount (BTC)') as HTMLInputElement
+ fireEvent.change(input, { target: { value: '0.0001' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Send Bitcoin' }))
+
+ expect(socket.emit).toHaveBeenCalledWith(
+ 'create-altpayment-quote',
+ expect.objectContaining({ depositAmount: '0.0001' }),
+ )
+ })
+
+ test('reports the typed amount back in the settle coin', () => {
+ render()
+
+ fireEvent.change(screen.getByLabelText('Amount (BTC)'), { target: { value: '0.0001' } })
+
+ // 0.0001 BTC at a rate of 9_500_000_000 XEC per BTC
+ expect(baseProps.updateAmount).toHaveBeenCalledWith('950000.00')
+ })
+
+ test('does not offer a coin step to go back to when the coin is preselected', () => {
+ render()
+
+ expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
+ })
+
+ test('keeps the back button when the user picked the coin manually', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'Back' })).toBeTruthy()
+ })
+})
diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx
index 1f634969..3312bbf6 100644
--- a/react/lib/tests/components/PayButton.test.tsx
+++ b/react/lib/tests/components/PayButton.test.tsx
@@ -568,3 +568,19 @@ describe('PayButton – hideSendButton in dialog', () => {
}
)
})
+
+// ─────────────────────────────────────────────────────────────
+// INVALID RECIPIENT
+// ─────────────────────────────────────────────────────────────
+describe('PayButton – invalid recipient', () => {
+ test('renders the button with an error instead of crashing', async () => {
+ // A Bitcoin address is not a valid recipient: the button used to throw
+ // "Invalid currency" while rendering and disappear from the page.
+ render()
+
+ expect(screen.getByRole('button', { name: /donate/i })).toBeTruthy()
+ await waitFor(() => {
+ expect(screen.getByText('Invalid Recipient')).toBeTruthy()
+ })
+ })
+})
diff --git a/react/lib/tests/util/api-client.test.ts b/react/lib/tests/util/api-client.test.ts
new file mode 100644
index 00000000..6eb30512
--- /dev/null
+++ b/react/lib/tests/util/api-client.test.ts
@@ -0,0 +1,94 @@
+import { getAddressDetails, resolveApiAddress } from '../../util/api-client';
+import { shouldTriggerOnSuccess } from '../../util/validate';
+
+const ADDRESS = 'ecash:qphvmsp9lg9qtq3jxvvy5t982usl73py3cy9u539d0';
+const INPUT_ADDRESS = 'ecash:qr57r0qvfflp6pprw234c3ppu8afznhqjv67j2gz0s';
+
+const mockResponse = (data: unknown, ok = true): void => {
+ (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({
+ ok,
+ json: async () => data,
+ });
+};
+
+describe('api-client', () => {
+ describe('resolveApiAddress', () => {
+ it('keeps plain string addresses', () => {
+ expect(resolveApiAddress(ADDRESS)).toBe(ADDRESS);
+ });
+
+ it('unwraps addresses sent as nested objects', () => {
+ expect(resolveApiAddress({ address: ADDRESS } as any)).toBe(ADDRESS);
+ });
+
+ it('returns an empty string for unusable values', () => {
+ expect(resolveApiAddress(undefined)).toBe('');
+ expect(resolveApiAddress(null)).toBe('');
+ expect(resolveApiAddress({} as any)).toBe('');
+ });
+ });
+
+ describe('getAddressDetails', () => {
+ it('normalizes transactions that carry the address as an object', async () => {
+ mockResponse([
+ {
+ hash: 'hash-1',
+ amount: '1001',
+ paymentId: '',
+ confirmed: true,
+ message: '',
+ rawMessage: '',
+ timestamp: 1772040277,
+ address: { id: 'some-uuid', address: ADDRESS, networkId: 1 },
+ inputAddresses: [{ address: { id: 'other-uuid', address: INPUT_ADDRESS }, amount: '5' }],
+ },
+ ]);
+
+ const transactions = await getAddressDetails(ADDRESS, 'http://api');
+
+ expect(transactions[0].address).toBe(ADDRESS);
+ expect(transactions[0].inputAddresses).toEqual([INPUT_ADDRESS]);
+ });
+
+ it('falls back to the queried address when the API sends none', async () => {
+ mockResponse([
+ {
+ hash: 'hash-2',
+ amount: '1001',
+ paymentId: '',
+ confirmed: true,
+ message: '',
+ rawMessage: '',
+ timestamp: 1772040277,
+ },
+ ]);
+
+ const transactions = await getAddressDetails(ADDRESS, 'http://api');
+
+ expect(transactions[0].address).toBe(ADDRESS);
+ });
+
+ it('produces transactions that address parsing can consume', async () => {
+ mockResponse([
+ {
+ hash: 'hash-3',
+ amount: '1001',
+ paymentId: '',
+ confirmed: true,
+ message: '',
+ rawMessage: '',
+ timestamp: 1772040277,
+ address: { id: 'some-uuid', address: ADDRESS, networkId: 1 },
+ },
+ ]);
+
+ const transactions = await getAddressDetails(ADDRESS, 'http://api');
+
+ // Before normalization this threw "Invalid address prefix." as an
+ // unhandled rejection whenever the widget checked for transactions.
+ expect(() =>
+ shouldTriggerOnSuccess(transactions[0], 'XEC', 0, false, true),
+ ).not.toThrow();
+ });
+ });
+});
diff --git a/react/lib/util/address.ts b/react/lib/util/address.ts
index 5569b639..73ea6f25 100644
--- a/react/lib/util/address.ts
+++ b/react/lib/util/address.ts
@@ -33,10 +33,24 @@ export const getCurrencyTypeFromAddress = (address: string): CryptoCurrency => {
}
};
+// Rendering must not blow up on a mistyped address: components use this to keep
+// showing their "Invalid Recipient" message instead of crashing.
+export const getCurrencyTypeFromAddressOrDefault = (
+ address: string,
+ fallback: CryptoCurrency = 'XEC',
+): CryptoCurrency => {
+ try {
+ return getCurrencyTypeFromAddress(address);
+ } catch {
+ return fallback;
+ }
+};
+
export default {
isValidCashAddress,
isValidXecAddress,
getCurrencyTypeFromAddress,
+ getCurrencyTypeFromAddressOrDefault,
};
diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts
index 56d3d80c..760387d1 100644
--- a/react/lib/util/api-client.ts
+++ b/react/lib/util/api-client.ts
@@ -12,6 +12,8 @@ import {
import { isFiat } from './currency';
import { CURRENCY_TYPES_MAP, DECIMALS } from './constants';
+type ApiAddress = string | { address?: string } | null | undefined
+
interface SimplifiedTransaction {
hash: string
amount: string
@@ -19,14 +21,14 @@ interface SimplifiedTransaction {
confirmed?: boolean
message: string
timestamp: number
- address: string
+ address: ApiAddress
rawMessage: string
inputAddresses: Array<{
- address: string
+ address: ApiAddress
amount: string
}>
outputAddresses: Array<{
- address: string
+ address: ApiAddress
amount: string
}>
prices: Array<{
@@ -37,6 +39,19 @@ interface SimplifiedTransaction {
}>
}
+// Some API versions return the address as a nested object instead of a plain
+// string. Everything downstream expects a string, and feeding it an object
+// makes address parsing throw, so normalize it as soon as it comes in.
+export const resolveApiAddress = (address: ApiAddress): string => {
+ if (typeof address === 'string') {
+ return address
+ }
+ if (address !== null && typeof address === 'object' && typeof address.address === 'string') {
+ return address.address
+ }
+ return ''
+}
+
export const getAddressDetails = async (
address: string,
rootUrl = config.apiBaseUrl,
@@ -75,10 +90,12 @@ export const getAddressDetails = async (
confirmed: apiTransaction.confirmed,
message: apiTransaction.message,
timestamp: apiTransaction.timestamp,
- address: apiTransaction.address,
+ // These transactions belong to the queried address, so use it as fallback
+ // whenever the API does not send a usable address back.
+ address: resolveApiAddress(apiTransaction.address) || address,
rawMessage: apiTransaction.rawMessage,
// Only keep the address string, drop the amount
- inputAddresses: Array.isArray(apiTransaction.inputAddresses) ? apiTransaction.inputAddresses.map((input: { address: string, amount: string }) => input.address) : [],
+ inputAddresses: Array.isArray(apiTransaction.inputAddresses) ? apiTransaction.inputAddresses.map(input => resolveApiAddress(input?.address)) : [],
opReturn: JSON.stringify(opReturn),
};
transactions.push(transaction);