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"} }'> +
+
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/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index a61aa6b1..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); @@ -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 @@ -408,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/AltpaymentWidget.tsx b/react/lib/components/Widget/AltpaymentWidget.tsx index 7d89c53a..5d1efdc9 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) { @@ -256,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 = { @@ -707,9 +770,25 @@ 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 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 && + !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.' @@ -871,6 +950,12 @@ export const AltpaymentWidget: React.FunctionComponent = props renderLoading('Loading Shift...') ) : coinPair && selectedCoin ? ( +
+ Swap coins with + + SideShift + +

{' '} 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 SideShift - {!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);