From a39b4a5a4eff5b146a6f31c2773bb4c244f98798 Mon Sep 17 00:00:00 2001 From: chedieck Date: Fri, 14 Aug 2026 15:06:40 -0300 Subject: [PATCH 1/6] fix: normalize transaction addresses returned by the API Some API versions return the transaction address (and each input address) as a nested object instead of a plain string. That object was copied straight into Transaction.address, and the next address parse threw "Invalid address prefix.", which surfaced on the host page as an uncaught promise rejection whenever the widget re-checked the transaction history (for instance on tab focus). Normalize the address as soon as it arrives, fall back to the queried address when the API sends none, and stop a failing transaction handler from rejecting unhandled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VBtWogj1C1LvKsLY5NTtMv --- .../lib/components/Widget/WidgetContainer.tsx | 6 +- react/lib/tests/util/api-client.test.ts | 94 +++++++++++++++++++ react/lib/util/api-client.ts | 27 +++++- 3 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 react/lib/tests/util/api-client.test.ts diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index 7883d879..57d7817e 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -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/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/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); From ff0a9f87005b44a6218e37c435c5b169cb680bd0 Mon Sep 17 00:00:00 2001 From: chedieck Date: Fri, 14 Aug 2026 15:06:48 -0300 Subject: [PATCH 2/6] fix: keep altpayment usable when the blockchain socket fails A failing chronik connection rejected inside an unawaited async effect, which both showed up as an uncaught error on the host page and skipped the SideShift socket setup entirely. Log the failure instead and carry on with the altpayment connection, which does not depend on chronik. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VBtWogj1C1LvKsLY5NTtMv --- react/lib/components/PayButton/PayButton.tsx | 44 +++++++++++--------- react/lib/components/Widget/Widget.tsx | 35 +++++++++------- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index a61aa6b1..0ee8ddfa 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -326,26 +326,30 @@ export const PayButton = ({ (async () => { if (txsSocket === undefined) { const expectedAmount = currencyObj ? currencyObj?.float : undefined - await setupChronikWebSocket({ - address: to, - txsSocket, - apiBaseUrl, - wsBaseUrl, - setTxsSocket, - setNewTxs, - setDialogOpen, - checkSuccessInfo: { - currency, - price, - randomSatoshis: randomSatoshis ?? false, - disablePaymentId, - expectedAmount, - expectedOpReturn: opReturn, - expectedPaymentId: paymentId, - currencyObj, - donationRate - } - }) + try { + await setupChronikWebSocket({ + address: to, + txsSocket, + apiBaseUrl, + wsBaseUrl, + setTxsSocket, + setNewTxs, + setDialogOpen, + checkSuccessInfo: { + currency, + price, + randomSatoshis: randomSatoshis ?? false, + disablePaymentId, + expectedAmount, + expectedOpReturn: opReturn, + expectedPaymentId: paymentId, + currencyObj, + donationRate + } + }) + } catch (err) { + console.error('Error connecting to the blockchain websocket:', err) + } } if (cancelled || !useAltpayment) { return diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 52deef23..1133a11b 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -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 () => { From 48a237bab693d1cc64b1121a6bec7d5fcba0f3be Mon Sep 17 00:00:00 2001 From: chedieck Date: Fri, 14 Aug 2026 15:06:58 -0300 Subject: [PATCH 3/6] fix: unblock altpayment="BTC" on editable buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a preselected coin the widget went straight to the "Loading SideShift..." screen and waited for a shift, but the automatic rate/quote requests were skipped whenever the amount was editable — which is also the case for buttons with no amount at all. The result was a spinner that never resolved. Editable buttons now request the rate as soon as the coin is preselected and show the amount form (prefilled with the converted amount, labelled with the deposit coin) instead of the automatic loading screen. An unrecognized ticker falls back to the regular coin selector, and every SideShift step gives up with an error message instead of spinning forever when the service never answers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VBtWogj1C1LvKsLY5NTtMv --- react/lib/altpayment/sideshift.ts | 2 +- .../components/Widget/AltpaymentWidget.tsx | 73 +++++++++++-- .../components/AltpaymentWidget.test.tsx | 102 +++++++++++++++++- 3 files changed, 168 insertions(+), 9 deletions(-) diff --git a/react/lib/altpayment/sideshift.ts b/react/lib/altpayment/sideshift.ts index b1ab926b..40b35489 100644 --- a/react/lib/altpayment/sideshift.ts +++ b/react/lib/altpayment/sideshift.ts @@ -68,7 +68,7 @@ export interface SideshiftShift { type: string; } -type ErrorType = 'quote-error' | 'shift-error' +type ErrorType = 'quote-error' | 'shift-error' | 'connection-error' export interface SideshiftError { errorType: ErrorType errorMessage: string diff --git a/react/lib/components/Widget/AltpaymentWidget.tsx b/react/lib/components/Widget/AltpaymentWidget.tsx index 7d89c53a..87a50ba4 100644 --- a/react/lib/components/Widget/AltpaymentWidget.tsx +++ b/react/lib/components/Widget/AltpaymentWidget.tsx @@ -41,6 +41,18 @@ interface AltpaymentProps { type ShiftCopyField = 'amount' | 'address' | 'id' +// How long we wait for SideShift data before giving up and showing an error, +// instead of leaving the user in front of a spinner forever. +export const ALTPAYMENT_TIMEOUT_MS = 25000 + +type PendingStage = 'coins' | 'pair' | 'shift' + +const PENDING_STAGE_TIMEOUT_MESSAGE: Record = { + coins: 'Could not reach SideShift. Please try again.', + pair: 'Could not get a SideShift rate. Please try again.', + shift: 'Could not create the SideShift order. Please try again.', +} + export const AltpaymentWidget: React.FunctionComponent = props => { const { @@ -163,7 +175,9 @@ export const AltpaymentWidget: React.FunctionComponent = props } const decimals = getDepositDecimals(selectedCoin, selectedCoinNetwork, coinPair) setPairAmountFixedDecimals(depositAmount) - if (!altpaymentEditable) { + // On editable buttons the input is prefilled with the converted amount, + // but never overwritten once the user starts editing it. + if (!altpaymentEditable || pairAmount === undefined) { setPairAmount(depositAmount) } @@ -183,15 +197,18 @@ export const AltpaymentWidget: React.FunctionComponent = props } } + // The rate does not depend on the amount, so it can always be fetched as soon + // as a coin is preselected — including on editable buttons, where the user + // needs the rate to type an amount. useEffect(() => { if ( preselectedCoin && - !altpaymentEditable && selectedCoin !== undefined && selectedCoinNetwork !== undefined && coinPair === undefined && !loadingPair && !autoRateRequestedRef.current && + altpaymentError === undefined && altpaymentSocket !== undefined ) { autoRateRequestedRef.current = true @@ -202,16 +219,42 @@ export const AltpaymentWidget: React.FunctionComponent = props } }, [ preselectedCoin, - altpaymentEditable, selectedCoin, selectedCoinNetwork, coinPair, loadingPair, + altpaymentError, altpaymentSocket, addressType, setLoadingPair, ]) + const pendingStage: PendingStage | undefined = + altpaymentError !== undefined || altpaymentShift !== undefined + ? undefined + : coins.length === 0 + ? 'coins' + : loadingShift + ? 'shift' + : loadingPair + ? 'pair' + : undefined + + useEffect(() => { + if (pendingStage === undefined) { + return + } + const timeout = setTimeout(() => { + setLoadingPair(false) + setLoadingShift(false) + setAltpaymentError({ + errorType: 'connection-error', + errorMessage: PENDING_STAGE_TIMEOUT_MESSAGE[pendingStage], + }) + }, ALTPAYMENT_TIMEOUT_MS) + return () => clearTimeout(timeout) + }, [pendingStage, setAltpaymentError, setLoadingPair, setLoadingShift]) + useEffect(() => { return () => { if (copiedFieldTimeoutRef.current) { @@ -707,8 +750,18 @@ export const AltpaymentWidget: React.FunctionComponent = props const shiftQrValue = altpaymentShift ? getShiftQrValue(altpaymentShift) : '' - const isAutoStart = Boolean(preselectedCoin) - const isAutoStartLoading = isAutoStart && !altpaymentShift && !altpaymentError + // While the coin list is still loading we cannot know whether the preselected + // coin exists, so assume it does; once loaded, an unknown ticker falls back to + // the regular coin selector instead of leaving the user with an empty screen. + const isPreselectedCoinAvailable = + Boolean(preselectedCoin) && + (coins.length === 0 || coins.some(c => c.coin === preselectedCoin)) + + const isAutoStart = isPreselectedCoinAvailable + // Editable buttons still need the amount input, so they are never fully + // automatic: only non-editable ones go straight from opening to a shift. + const isAutoStartLoading = + isAutoStart && !altpaymentEditable && !altpaymentShift && !altpaymentError const showManualAmountBackButton = altpaymentEditable const amountValidationMessage = pairAmount && isAboveMinimumAltpaymentAmount === false @@ -871,6 +924,12 @@ export const AltpaymentWidget: React.FunctionComponent = props renderLoading('Loading Shift...') ) : coinPair && selectedCoin ? ( +
+ Swap coins with + + SideShift + +

{' '} 1 {selectedCoin.name} ~={' '} @@ -879,7 +938,7 @@ export const AltpaymentWidget: React.FunctionComponent = props {altpaymentEditable ? (

= props SideShift - {!preselectedCoin ? ( + {!isPreselectedCoinAvailable ? ( Select a coin { 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() + }) +}) From cf3286022193251a88a89709a403f6cf5561b819 Mon Sep 17 00:00:00 2001 From: chedieck Date: Fri, 14 Aug 2026 15:07:34 -0300 Subject: [PATCH 4/6] chore: add an editable BTC altpayment button to the demo page Covers the case where the user types the BTC amount instead of paying a fixed one, which previously never left the loading screen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VBtWogj1C1LvKsLY5NTtMv --- paybutton/dev/demo/index.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/paybutton/dev/demo/index.html b/paybutton/dev/demo/index.html index 6bcc6c0b..d7019ac4 100644 --- a/paybutton/dev/demo/index.html +++ b/paybutton/dev/demo/index.html @@ -44,6 +44,10 @@ text="Pay with BTC" on-success="mySuccessFunction" on-transaction="myTransactionFunction" altpayment="BTC" theme='{ "palette": { "primary": "#F18F01", "secondary": "#ffffff", "tertiary": "#333333"} }'>
+
+
From ddf5559abf613f3eae1948d95b15609a9a6fce03 Mon Sep 17 00:00:00 2001 From: chedieck Date: Fri, 14 Aug 2026 15:17:53 -0300 Subject: [PATCH 5/6] fix: show "Invalid Recipient" instead of crashing on a bad address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mistyped or non-eCash/BCH address made getCurrencyTypeFromAddress throw while rendering, so the whole button vanished from the page with an "Invalid currency" error in the console — even though both PayButton and Widget already have an "Invalid Recipient" message for exactly this case. Components now fall back to a default ticker when the address cannot be parsed and let that message render. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VBtWogj1C1LvKsLY5NTtMv --- react/lib/components/PayButton/PayButton.tsx | 6 +++--- react/lib/components/Widget/Widget.tsx | 6 +++--- react/lib/components/Widget/WidgetContainer.tsx | 6 +++--- react/lib/tests/components/PayButton.test.tsx | 16 ++++++++++++++++ react/lib/util/address.ts | 14 ++++++++++++++ 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 0ee8ddfa..87a52663 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -9,7 +9,7 @@ import { Currency, isFiat, getFiatPrice, - getCurrencyTypeFromAddress, + getCurrencyTypeFromAddressOrDefault, isValidCashAddress, isValidXecAddress, CurrencyObject, @@ -122,7 +122,7 @@ export const PayButton = ({ const [paymentId, setPaymentId] = useState(undefined); const [addressType, setAddressType] = useState( - getCurrencyTypeFromAddress(to), + getCurrencyTypeFromAddressOrDefault(to), ); const altpaymentSocketRef = useRef(undefined); @@ -412,7 +412,7 @@ export const PayButton = ({ useEffect(() => { if (currencyObj && isFiat(currency) && price) { - const addressType: Currency = getCurrencyTypeFromAddress(to); + const addressType: Currency = getCurrencyTypeFromAddressOrDefault(to); const convertedObj = getCurrencyObject( currencyObj.float / price, addressType, diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 1133a11b..6aaa4d95 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -32,7 +32,7 @@ import { encodeOpReturnProps, isValidCashAddress, isValidXecAddress, - getCurrencyTypeFromAddress, + getCurrencyTypeFromAddressOrDefault, CURRENCY_PREFIXES_MAP, CRYPTO_CURRENCIES, isPropsTrue, @@ -165,7 +165,7 @@ export const Widget: React.FunctionComponent = 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 diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index 57d7817e..ccbd2ed1 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -11,7 +11,7 @@ import { Currency, CurrencyObject, Transaction, - getCurrencyTypeFromAddress, + getCurrencyTypeFromAddressOrDefault, isCrypto, isGreaterThanZero, isValidCurrency, @@ -168,7 +168,7 @@ export const WidgetContainer: React.FunctionComponent = 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, 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/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, }; From 2f8114cb3f12f3b253de4ce516644d2d17656e78 Mon Sep 17 00:00:00 2001 From: chedieck Date: Fri, 14 Aug 2026 16:08:07 -0300 Subject: [PATCH 6/6] fix: quote the amount the user typed on editable altpayment buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing an amount fed the converted settle amount back into the button amount, which for fiat buttons is denominated in the fiat currency: the value grew on every round trip, and the quote — built from that derived value rather than from the input — asked SideShift for a wildly larger deposit ("Amount too high. Maximum deposit amount: …") on a perfectly valid amount. The quote now uses the typed amount directly, and the widget converts the settle amount back into the button currency before updating it. Also stop the coin/network pickers from flashing by before the rate arrives when the coin is preselected, and drop the back button that pointed at a coin step that does not exist in that case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VBtWogj1C1LvKsLY5NTtMv --- .../components/Widget/AltpaymentWidget.tsx | 36 +++++++++++-- react/lib/components/Widget/Widget.tsx | 18 ++++++- .../components/AltpaymentWidget.test.tsx | 51 +++++++++++++++++++ 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/react/lib/components/Widget/AltpaymentWidget.tsx b/react/lib/components/Widget/AltpaymentWidget.tsx index 87a50ba4..5d1efdc9 100644 --- a/react/lib/components/Widget/AltpaymentWidget.tsx +++ b/react/lib/components/Widget/AltpaymentWidget.tsx @@ -299,13 +299,33 @@ export const AltpaymentWidget: React.FunctionComponent = props } }; + // When the amount is editable, what the user typed is the source of truth: + // deriving it back from the button amount can drift through the conversions in + // between and end up asking SideShift for a completely different amount. + const getTypedDepositAmount = (): string | undefined => { + if ( + coinPair === undefined || + selectedCoin === undefined || + selectedCoinNetwork === undefined || + pairAmount === undefined || + pairAmount === '' || + Number.isNaN(+pairAmount) || + +pairAmount <= 0 + ) { + return undefined + } + return resolveNumber(+pairAmount).toFixed( + getDepositDecimals(selectedCoin, selectedCoinNetwork, coinPair), + ) + } + const createQuote = (): boolean => { if (altpaymentSocket === undefined || selectedCoin === undefined || selectedCoinNetwork === undefined) { return false } const depositAmount = altpaymentEditable - ? pairAmountFixedDecimals + ? getTypedDepositAmount() : (pairAmountFixedDecimals ?? computeDepositAmountFromSettle()) const quotePayload: Record = { @@ -758,11 +778,17 @@ export const AltpaymentWidget: React.FunctionComponent = props (coins.length === 0 || coins.some(c => c.coin === preselectedCoin)) const isAutoStart = isPreselectedCoinAvailable - // Editable buttons still need the amount input, so they are never fully - // automatic: only non-editable ones go straight from opening to a shift. + // Editable buttons still need the amount input, so they stay on the loading + // screen only until the rate is in; non-editable ones go straight from + // opening to a ready shift. Either way the coin and network pickers never + // flash by, since nothing there is up to the user. const isAutoStartLoading = - isAutoStart && !altpaymentEditable && !altpaymentShift && !altpaymentError + isAutoStart && + !altpaymentError && + (altpaymentEditable ? coinPair === undefined : altpaymentShift === undefined) const showManualAmountBackButton = altpaymentEditable + // With a preselected coin there is no coin/network step to go back to. + const showRateBackButton = altpaymentEditable && !isPreselectedCoinAvailable const amountValidationMessage = pairAmount && isAboveMinimumAltpaymentAmount === false ? 'Amount is below minimum.' @@ -978,7 +1004,7 @@ export const AltpaymentWidget: React.FunctionComponent = props > {amountValidationMessage || '\u00A0'} - {showManualAmountBackButton ? ( + {showRateBackButton ? ( Back diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 6aaa4d95..a32d0d6e 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -1121,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 => { { 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() + }) +})