diff --git a/CHANGELOG.md b/CHANGELOG.md index d103ba1b038..7a4924c59eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - changed: Upgrade edge-login-ui-rn to v4.0.0 - changed: Android 11 and below now show solid backgrounds where blur effects used to be. Those Android versions cannot render blur under the new architecture, which painted a gray wash over the content instead. - changed: On Android 16 and above, the back button and back gesture reach the app through predictive back, which React Native 0.86 supports, so the app no longer opts out of it. +- fixed: Swaps with no exact receive quote switch to a send amount of the same value with a toast, not a no-provider error - fixed: The side menu's dimming overlay works again on both platforms. React Native 0.86 removed the `StyleSheet.absoluteFillObject` the drawer's overlay spreads, which collapsed it to nothing, so tapping outside the menu no longer closed it. - fixed: Text inputs mounted in the disabled state (such as the spending-limit amount) no longer flash their enabled look before dimming when a scene appears. - fixed: Received-transaction and error dropdowns no longer slide in underneath the Android status bar on edge-to-edge devices. diff --git a/eslint.config.mjs b/eslint.config.mjs index 19b66d5a57a..7624cfd29fb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -480,7 +480,7 @@ export default [ 'src/util/stakeUtils.ts', 'src/util/ukComplianceUtils.ts', - 'src/util/utils.ts', + 'src/util/WebUtils.ts', 'src/util/withWatchableProps.ts' ], diff --git a/src/__tests__/scenes/SwapCreateScene.test.tsx b/src/__tests__/scenes/SwapCreateScene.test.tsx index c99baf3f5f8..278d5283d4d 100644 --- a/src/__tests__/scenes/SwapCreateScene.test.tsx +++ b/src/__tests__/scenes/SwapCreateScene.test.tsx @@ -8,7 +8,12 @@ import { it, jest } from '@jest/globals' -import { act, fireEvent, render } from '@testing-library/react-native' +import { + act, + fireEvent, + render, + type RenderResult +} from '@testing-library/react-native' import { asDate, asObject, asOptional, asString, asUnknown } from 'cleaners' import { addEdgeCorePlugins, @@ -271,6 +276,169 @@ describe('SwapCreateScene', () => { setParams.mockRestore() }) + describe('"to" quote fallback', () => { + /** Renders the scene, enters a "to" amount, and presses Next. */ + const requestToQuote = async ( + nativeAmount: string, + rootState: FakeState = { ...servedState, core: { account } }, + fiatAmount: string = '100' + ): Promise<{ + lastParams: () => SwapProcessingParams + params: SwapProcessingParams + replace: jest.SpiedFunction + rendered: RenderResult + }> => { + if (btcWallet == null || ethWallet == null) throw new Error('No wallets') + const navigate = jest + .spyOn(fakeNavigation, 'navigate') + .mockImplementation(() => undefined) + const replace = jest + .spyOn(fakeNavigation, 'replace') + .mockImplementation(() => undefined) + const rendered = render( + + + + ) + const toInput = rendered + .UNSAFE_getAllByType(SwapInput) + .find(node => node.props.walletPillTestID === 'swapToWalletPill') + if (toInput == null) throw new Error('No to input') + + act(() => { + toInput.props.onAmountChanged({ + exchangeAmount: '1', + fiatAmount, + nativeAmount, + fieldChanged: 'crypto' + }) + }) + fireEvent.press( + rendered.getAllByText(lstrings.string_next_capitalized)[0] + ) + + const lastParams = (): SwapProcessingParams => { + const calls = navigate.mock.calls as unknown as Array<[string, unknown]> + const processingCalls = calls.filter( + call => call[0] === 'swapProcessing' + ) + return processingCalls[ + processingCalls.length - 1 + ][1] as SwapProcessingParams + } + return { lastParams, params: lastParams(), rendered, replace } + } + + /** A root state pricing BTC at $100,000 unless told, and ETH at $4,000. */ + const pricedState = (btcRate: number = 100000): FakeState => { + const rate = (current: number): object => ({ + 'iso:EUR': { + current, + yesterday: 0, + expiration: 0, + yesterdayTimestamp: 0 + } + }) + return { + ...servedState, + core: { account }, + exchangeRates: { + ...fakeRootState.exchangeRates, + crypto: { + bitcoin: { '': rate(btcRate) }, + ethereum: { '': rate(4000) } + } + } as unknown as FakeState['exchangeRates'] + } + } + + it('offers a "from" request of the same value', async () => { + const { params, rendered } = await requestToQuote( + '1000000000000000000', + pricedState() + ) + + expect(params.swapRequest.quoteFor).toBe('to') + expect(params.toQuoteFallback?.swapRequest).toEqual({ + ...params.swapRequest, + quoteFor: 'from', + nativeAmount: '4000000' + }) + rendered.unmount() + }) + + it('rounds the switched send amount like a typed fiat amount', async () => { + const { params, rendered } = await requestToQuote( + '333333333333333333', + pricedState(3000), + '1333.33' + ) + // The exact amount would be 44444444, a digit past what matters at $3,000: + expect(params.toQuoteFallback?.swapRequest.nativeAmount).toBe('44444440') + + act(() => { + params.toQuoteFallback?.onSwitch() + }) + + // The card shows exactly the amount the request carries: + expect(rendered.getByText('0.4444444 BTC')).toBeTruthy() + rendered.unmount() + }) + + it('offers no fallback without a rate on both sides', async () => { + const state = pricedState() + const { params, rendered } = await requestToQuote('1000000000000000000', { + ...state, + exchangeRates: { + ...state.exchangeRates, + crypto: { ethereum: state.exchangeRates?.crypto?.ethereum } + } as unknown as FakeState['exchangeRates'] + }) + + expect(params.toQuoteFallback).toBeUndefined() + rendered.unmount() + }) + + it('moves the amount to the send side and confirms the new request', async () => { + const { lastParams, params, rendered, replace } = await requestToQuote( + '1000000000000000000', + pricedState() + ) + + await act(async () => { + params.toQuoteFallback?.onSwitch() + }) + await act(async () => { + params.onDone([]) + }) + + const confirmationCall = replace.mock.calls.find( + call => call[0] === 'swapConfirmation' + ) + const confirmationParams = confirmationCall?.[1] as SwapConfirmationParams + expect(confirmationParams.swapRequest.quoteFor).toBe('from') + expect(confirmationParams.swapRequest.nativeAmount).toBe('4000000') + + // The scene now holds the send amount the toast promised, so pressing Next + // again asks for that amount directly: + fireEvent.press( + rendered.getAllByText(lstrings.string_next_capitalized)[0] + ) + const nextParams = lastParams() + expect(nextParams.swapRequest.quoteFor).toBe('from') + expect(nextParams.swapRequest.nativeAmount).toBe('4000000') + expect(nextParams.toQuoteFallback).toBeUndefined() + rendered.unmount() + }) + }) + describe('saved Stealth Swap choice', () => { beforeEach(() => { mockSetStealthSwapEnabled.mockReset() diff --git a/src/__tests__/scenes/SwapProcessingScene.test.tsx b/src/__tests__/scenes/SwapProcessingScene.test.tsx new file mode 100644 index 00000000000..46a22e7f643 --- /dev/null +++ b/src/__tests__/scenes/SwapProcessingScene.test.tsx @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals' +import { act, render } from '@testing-library/react-native' +import { + type EdgeAccount, + type EdgeCurrencyWallet, + type EdgeSwapQuote, + type EdgeSwapRequest, + SwapAboveLimitError, + SwapCurrencyError +} from 'edge-core-js' +import * as React from 'react' + +import { + SwapProcessingScene, + type ToQuoteFallback +} from '../../components/scenes/SwapProcessingScene' +import { showToast } from '../../components/services/AirshipInstance' +import { lstrings } from '../../locales/strings' +import { btcCurrencyInfo } from '../../util/fake/fakeBtcInfo' +import { ethCurrencyInfo } from '../../util/fake/fakeEthInfo' +import { FakeProviders, type FakeState } from '../../util/fake/FakeProviders' +import { fakeRootState } from '../../util/fake/fakeRootState' +import { + fakeNavigation, + fakeSwapTabSceneProps +} from '../../util/fake/fakeSceneProps' + +jest.mock('../../components/services/AirshipInstance', () => ({ + ...jest.requireActual('../../components/services/AirshipInstance'), + showToast: jest.fn() +})) + +jest.useRealTimers() + +const fromWallet = { + id: 'fromWallet', + currencyInfo: btcCurrencyInfo, + currencyConfig: { allTokens: {}, currencyInfo: btcCurrencyInfo } +} as unknown as EdgeCurrencyWallet +const toWallet = { + id: 'toWallet', + currencyInfo: ethCurrencyInfo, + currencyConfig: { allTokens: {}, currencyInfo: ethCurrencyInfo } +} as unknown as EdgeCurrencyWallet + +const toRequest: EdgeSwapRequest = { + fromTokenId: null, + fromWallet, + nativeAmount: '1000000000000000000', + quoteFor: 'to', + toTokenId: null, + toWallet +} +const fromRequest: EdgeSwapRequest = { + ...toRequest, + nativeAmount: '4000000', + quoteFor: 'from' +} + +const quote = { pluginId: 'fakeSwap' } as unknown as EdgeSwapQuote +const currencyError = new SwapCurrencyError( + { pluginId: 'fakeSwap', displayName: 'Fake', supportEmail: '' }, + toRequest +) + +type FetchSwapQuotes = (request: EdgeSwapRequest) => Promise + +interface SceneOpts { + fetchSwapQuotes: FetchSwapQuotes + toQuoteFallback?: ToQuoteFallback + swapRequest?: EdgeSwapRequest +} + +const renderScene = async ( + opts: SceneOpts +): Promise<{ + fetch: jest.Mock + navigate: jest.SpiedFunction + onDone: jest.Mock<(quotes: EdgeSwapQuote[]) => void> +}> => { + const fetch = jest.fn(opts.fetchSwapQuotes) + const onDone = jest.fn<(quotes: EdgeSwapQuote[]) => void>() + const navigate = jest + .spyOn(fakeNavigation, 'navigate') + .mockImplementation(() => undefined) + const account = { fetchSwapQuotes: fetch } as unknown as EdgeAccount + const rootState: FakeState = { ...fakeRootState, core: { account } } + + render( + + {}, + onDone + })} + /> + + ) + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + return { fetch, navigate, onDone } +} + +/** The error the scene handed back to the create scene, as user-facing text. */ +const shownErrorMessage = ( + navigate: jest.SpiedFunction +): string | undefined => { + const calls = navigate.mock.calls as unknown as Array< + [string, { params: { errorDisplayInfo?: { message: string } } }] + > + return calls[calls.length - 1]?.[1].params.errorDisplayInfo?.message +} + +describe('SwapProcessingScene "to" quote fallback', () => { + afterEach(() => { + jest.restoreAllMocks() + jest.mocked(showToast).mockClear() + }) + + const makeFallback = (): ToQuoteFallback & { + onSwitch: jest.Mock<() => void> + } => ({ swapRequest: fromRequest, onSwitch: jest.fn() }) + + it('leaves a quotable "to" request alone', async () => { + const toQuoteFallback = makeFallback() + const { fetch, onDone } = await renderScene({ + fetchSwapQuotes: async () => [quote], + toQuoteFallback + }) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(onDone).toHaveBeenCalledWith([quote]) + expect(toQuoteFallback.onSwitch).not.toHaveBeenCalled() + expect(showToast).not.toHaveBeenCalled() + }) + + it('switches to the "from" quote when the route exists', async () => { + const toQuoteFallback = makeFallback() + const { fetch, onDone } = await renderScene({ + fetchSwapQuotes: async request => { + if (request.quoteFor === 'to') throw currencyError + return [quote] + }, + toQuoteFallback + }) + + expect(fetch.mock.calls.map(call => call[0].quoteFor)).toEqual([ + 'to', + 'from' + ]) + expect(fetch.mock.calls[1][0].nativeAmount).toBe(fromRequest.nativeAmount) + expect(onDone).toHaveBeenCalledWith([quote]) + expect(toQuoteFallback.onSwitch).toHaveBeenCalledTimes(1) + expect(showToast).toHaveBeenCalledTimes(1) + expect(showToast).toHaveBeenCalledWith( + lstrings.swap_fixed_to_unavailable_toast + ) + }) + + it('also switches when every provider declines without an error', async () => { + const toQuoteFallback = makeFallback() + const { onDone } = await renderScene({ + fetchSwapQuotes: async request => + request.quoteFor === 'to' ? [] : [quote], + toQuoteFallback + }) + + expect(onDone).toHaveBeenCalledWith([quote]) + expect(toQuoteFallback.onSwitch).toHaveBeenCalledTimes(1) + }) + + it('keeps the amounts and reports the pair when neither direction routes', async () => { + const toQuoteFallback = makeFallback() + const { fetch, navigate, onDone } = await renderScene({ + fetchSwapQuotes: async () => { + throw currencyError + }, + toQuoteFallback + }) + + expect(fetch).toHaveBeenCalledTimes(2) + expect(onDone).not.toHaveBeenCalled() + expect(toQuoteFallback.onSwitch).not.toHaveBeenCalled() + expect(showToast).not.toHaveBeenCalled() + expect(shownErrorMessage(navigate)).toBeDefined() + }) + + it('switches before reporting a limit on the new amount', async () => { + const toQuoteFallback = makeFallback() + const limitError = new SwapAboveLimitError( + { pluginId: 'fakeSwap', displayName: 'Fake', supportEmail: '' }, + '100', + 'from' + ) + const { navigate, onDone } = await renderScene({ + fetchSwapQuotes: async request => { + if (request.quoteFor === 'to') throw currencyError + throw limitError + }, + toQuoteFallback + }) + + expect(onDone).not.toHaveBeenCalled() + expect(toQuoteFallback.onSwitch).toHaveBeenCalledTimes(1) + expect(showToast).toHaveBeenCalledTimes(1) + expect(shownErrorMessage(navigate)).toContain('0.000001') + }) + + it('does not retry an amount error on the "to" quote', async () => { + const toQuoteFallback = makeFallback() + const limitError = new SwapAboveLimitError( + { pluginId: 'fakeSwap', displayName: 'Fake', supportEmail: '' }, + '100', + 'to' + ) + const { fetch } = await renderScene({ + fetchSwapQuotes: async () => { + throw limitError + }, + toQuoteFallback + }) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(toQuoteFallback.onSwitch).not.toHaveBeenCalled() + }) + + it('reports the provider error when there is no fallback', async () => { + const { fetch, navigate } = await renderScene({ + fetchSwapQuotes: async () => { + throw currencyError + } + }) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(showToast).not.toHaveBeenCalled() + expect(shownErrorMessage(navigate)).toBeDefined() + }) +}) diff --git a/src/__tests__/selectors/WalletSelectors.test.ts b/src/__tests__/selectors/WalletSelectors.test.ts new file mode 100644 index 00000000000..7be487d5d8d --- /dev/null +++ b/src/__tests__/selectors/WalletSelectors.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from '@jest/globals' + +import type { GuiExchangeRates } from '../../actions/ExchangeRateActions' +import { + type ConvertibleAsset, + convertNativeBetweenAssets +} from '../../selectors/WalletSelectors' + +const makeRates = (rates: Record): GuiExchangeRates => { + const crypto: Record = {} + for (const pluginId of Object.keys(rates)) { + crypto[pluginId] = { + '': { + 'iso:USD': { + current: rates[pluginId], + yesterday: 0, + yesterdayTimestamp: 0, + expiration: 0 + } + } + } + } + return { crypto, fiat: {} } as unknown as GuiExchangeRates +} + +const btc: ConvertibleAsset = { + pluginId: 'bitcoin', + tokenId: null, + multiplier: '100000000' +} +const eth: ConvertibleAsset = { + pluginId: 'ethereum', + tokenId: null, + multiplier: '1000000000000000000' +} + +describe('convertNativeBetweenAssets', () => { + it('returns the amount worth the same, in the target denomination', () => { + const rates = makeRates({ bitcoin: 100000, ethereum: 4000 }) + + // 1 ETH at $4,000 is 0.04 BTC at $100,000: + expect( + convertNativeBetweenAssets( + rates, + 'iso:USD', + '1000000000000000000', + eth, + btc + ) + ).toBe('4000000') + }) + + it('drops the fraction of a native unit', () => { + const rates = makeRates({ bitcoin: 3, ethereum: 1 }) + + expect( + convertNativeBetweenAssets( + rates, + 'iso:USD', + '1000000000000000000', + eth, + btc + ) + ).toBe('33333333') + }) + + it('handles a rate small enough to print in scientific notation', () => { + const rates = makeRates({ bitcoin: 100000, ethereum: 1e-7 }) + + expect( + convertNativeBetweenAssets(rates, 'iso:USD', '100000000', btc, eth) + ).toBe('1000000000000000000000000000000') + }) + + it('returns undefined when either asset has no rate', () => { + const rates = makeRates({ bitcoin: 100000 }) + + expect( + convertNativeBetweenAssets(rates, 'iso:USD', '100', eth, btc) + ).toBeUndefined() + expect( + convertNativeBetweenAssets(rates, 'iso:USD', '100', btc, eth) + ).toBeUndefined() + }) +}) diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index e61fefb3c3d..a13dbce91a3 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -17,6 +17,7 @@ import { MILLISECONDS_PER_DAY, precisionAdjust, roundedFee, + roundToFiatPrecision, roundUpToLeastSignificant, sanitizeDecimalAmount, truncateDecimals, @@ -342,6 +343,35 @@ describe('precisionAdjust', function () { } }) +describe('roundToFiatPrecision', function () { + test('keeps the decimals that matter at the rate', function () { + expect( + roundToFiatPrecision({ + nativeAmount: '17710602793722079', + displayMultiplier: '1000000000000000000', + exchangeMultiplier: '1000000000000000000', + fiatMultiplier: '100', + exchangeRate: 2258.5 + }) + ).toEqual({ + displayAmount: '0.0177106', + nativeAmount: '17710600000000000' + }) + }) + + test('rounds in the display denomination', function () { + expect( + roundToFiatPrecision({ + nativeAmount: '44444444', + displayMultiplier: '100000', + exchangeMultiplier: '100000000', + fiatMultiplier: '100', + exchangeRate: 3000 + }) + ).toEqual({ displayAmount: '444.4444', nativeAmount: '44444440' }) + }) +}) + describe('getDisplayDenomination', function () { const state = { ui: { diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index c1fd20d211d..f9dc68e2e8e 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -29,6 +29,14 @@ import { useLinkPromoRelease } from '../../hooks/useLinkPromoRelease' import { useTabBlur } from '../../hooks/useTabBlur' import { useWatch } from '../../hooks/useWatch' import { lstrings } from '../../locales/strings' +import { + getExchangeDenom, + selectDisplayDenom +} from '../../selectors/DenominationSelectors' +import { + convertNativeBetweenAssets, + getExchangeRate +} from '../../selectors/WalletSelectors' import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' @@ -44,7 +52,11 @@ import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' import type { SwapErrorDisplayInfo } from '../../util/swapErrorDisplay' -import { zeroString } from '../../util/utils' +import { + getDenomFromIsoCode, + roundToFiatPrecision, + zeroString +} from '../../util/utils' import { EdgeButton } from '../buttons/EdgeButton' import { KavButtons } from '../buttons/KavButtons' import { SceneButtons } from '../buttons/SceneButtons' @@ -145,6 +157,8 @@ export const SwapCreateScene: React.FC = props => { const account = useSelector(state => state.core.account) const currencyWallets = useWatch(account, 'currencyWallets') const exchangeInfo = useSelector(state => state.ui.exchangeInfo) + const exchangeRates = useSelector(state => state.exchangeRates) + const defaultIsoFiat = useSelector(state => state.ui.settings.defaultIsoFiat) const houdiniChains = useSelector(state => state.ui.houdiniChains) // Until the info server serves Houdini's list there is nothing a Stealth // Swap could route, so the toggle stays hidden and reads as off: @@ -154,6 +168,11 @@ export const SwapCreateScene: React.FC = props => { toWalletId == null ? undefined : currencyWallets[toWalletId] const fromWallet: EdgeCurrencyWallet | undefined = fromWalletId == null ? undefined : currencyWallets[fromWalletId] + const fromDisplayDenom = useSelector(state => + fromWallet == null + ? undefined + : selectDisplayDenom(state, fromWallet.currencyConfig, fromTokenId) + ) const toWalletName = toWallet == null ? '' : getWalletName(toWallet) const fromWalletName = fromWallet == null ? '' : getWalletName(fromWallet) @@ -304,6 +323,59 @@ export const SwapCreateScene: React.FC = props => { ) } + /** + * The "from" request to fall back on when no provider can price the exact + * receive amount of a "to" request: the send amount worth the same at display + * rates. Without a rate on both sides there is nothing to seed it with, so the + * provider's error stands. + * + * The amount is rounded like a typed fiat amount. A provider carrying amounts + * as floats, such as Houdini, can round an exact 18-decimal amount up past the + * request, and the plugin then rejects the deposit. + */ + const makeFromRequest = ( + toRequest: EdgeSwapRequest + ): EdgeSwapRequest | undefined => { + if (toRequest.quoteFor !== 'to' || fromDisplayDenom == null) return + const fromMultiplier = getExchangeDenom( + toRequest.fromWallet.currencyConfig, + toRequest.fromTokenId + ).multiplier + const exactAmount = convertNativeBetweenAssets( + exchangeRates, + defaultIsoFiat, + toRequest.nativeAmount, + { + pluginId: toRequest.toWallet.currencyInfo.pluginId, + tokenId: toRequest.toTokenId, + multiplier: getExchangeDenom( + toRequest.toWallet.currencyConfig, + toRequest.toTokenId + ).multiplier + }, + { + pluginId: toRequest.fromWallet.currencyInfo.pluginId, + tokenId: toRequest.fromTokenId, + multiplier: fromMultiplier + } + ) + if (exactAmount == null) return + const { nativeAmount } = roundToFiatPrecision({ + nativeAmount: exactAmount, + displayMultiplier: fromDisplayDenom.multiplier, + exchangeMultiplier: fromMultiplier, + fiatMultiplier: getDenomFromIsoCode(defaultIsoFiat).multiplier, + exchangeRate: getExchangeRate( + exchangeRates, + toRequest.fromWallet.currencyInfo.pluginId, + toRequest.fromTokenId, + defaultIsoFiat + ) + }) + if (zeroString(nativeAmount)) return + return { ...toRequest, quoteFor: 'from', nativeAmount } + } + const getQuote = (swapRequest: EdgeSwapRequest): void => { if (exchangeInfo != null) { // A Stealth Swap asks Houdini alone, so Houdini's own bans refuse the @@ -361,9 +433,31 @@ export const SwapCreateScene: React.FC = props => { const quoteRequestOptions = stealth ? makeStealthSwapRequestOptions(account, swapRequestOptions) : swapRequestOptions + + // The confirmation scene replays this request when its quotes expire, so it + // follows the switch to the fallback: + let activeRequest = quoteRequest + const fromRequest = makeFromRequest(quoteRequest) + navigation.navigate('swapProcessing', { swapRequest: quoteRequest, swapRequestOptions: quoteRequestOptions, + toQuoteFallback: + fromRequest == null + ? undefined + : { + swapRequest: fromRequest, + onSwitch: () => { + activeRequest = fromRequest + setInputNativeAmount(fromRequest.nativeAmount) + setInputNativeAmountFor('from') + fromInputRef.current?.setAmount( + 'crypto', + fromRequest.nativeAmount + ) + toInputRef.current?.setAmount('crypto', '0') + } + }, onCancel: () => { navigation.goBack() }, @@ -373,7 +467,7 @@ export const SwapCreateScene: React.FC = props => { quotes, onApprove: resetState, onStealthTermsDeclined: handleStealthTermsDeclined, - swapRequest: quoteRequest, + swapRequest: activeRequest, swapRequestOptions: quoteRequestOptions }) } diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx index 60e0a689216..801a63470a4 100644 --- a/src/components/scenes/SwapProcessingScene.tsx +++ b/src/components/scenes/SwapProcessingScene.tsx @@ -1,6 +1,7 @@ import { asMaybeInsufficientFundsError, asMaybeSwapAddressError, + asMaybeSwapCurrencyError, type EdgeSwapQuote, type EdgeSwapRequest, type EdgeSwapRequestOptions @@ -20,11 +21,21 @@ import { ButtonsModal } from '../modals/ButtonsModal' import { showInsufficientFeesModal } from '../modals/InsufficientFeesModal' import { showPendingTxModal } from '../modals/PendingTxModal' import { CancellableProcessingScene } from '../progress-indicators/CancellableProcessingScene' -import { Airship } from '../services/AirshipInstance' +import { Airship, showToast } from '../services/AirshipInstance' + +/** + * What to do when no provider can price an exact receive amount: quote this + * "from" request instead, and tell the caller its amounts changed. + */ +export interface ToQuoteFallback { + swapRequest: EdgeSwapRequest + onSwitch: () => void +} export interface SwapProcessingParams { swapRequest: EdgeSwapRequest swapRequestOptions: EdgeSwapRequestOptions + toQuoteFallback?: ToQuoteFallback onCancel: () => void onDone: (quotes: EdgeSwapQuote[]) => void } @@ -33,7 +44,13 @@ type Props = SwapTabSceneProps<'swapProcessing'> export const SwapProcessingScene: React.FC = (props: Props) => { const { route, navigation } = props - const { swapRequest, swapRequestOptions, onCancel, onDone } = route.params + const { + swapRequest: initialRequest, + swapRequestOptions, + toQuoteFallback, + onCancel, + onDone + } = route.params const account = useSelector(state => state.core.account) const countryCode = useSelector(state => state.ui.countryCode) @@ -42,22 +59,82 @@ export const SwapProcessingScene: React.FC = (props: Props) => { ) const fromDenomination = useDisplayDenom( - swapRequest.fromWallet.currencyConfig, - swapRequest.fromTokenId + initialRequest.fromWallet.currencyConfig, + initialRequest.fromTokenId ) const toDenomination = useDisplayDenom( - swapRequest.toWallet.currencyConfig, - swapRequest.toTokenId + initialRequest.toWallet.currencyConfig, + initialRequest.toTokenId ) - const doWork = async (isCancelled: () => boolean): Promise => { - const quotes = await account.fetchSwapQuotes(swapRequest, { + // The request this scene is working on. It changes at most once, when a "to" + // quote gives way to its fallback. A re-render can run `doWork` again, and + // that run must stay on the fallback without switching a second time. + const activeRequest = React.useRef(initialRequest) + + const fetchQuotes = async ( + request: EdgeSwapRequest + ): Promise => { + return await account.fetchSwapQuotes(request, { ...swapRequestOptions, disabled: { ...swapRequestOptions.disabled, - ...getDisabledSwapPlugins(disableAssetsByPlugin, swapRequest) + ...getDisabledSwapPlugins(disableAssetsByPlugin, request) } }) + } + + /** + * Quotes the active request. A "to" request nothing can route tries its + * fallback, which tells a quote-direction limit apart from an unsupported + * pair: if the "from" quote cannot route either, the pair is the problem, so + * the original error stands and the user's amounts stay as they typed them. + * Any other outcome means the pair has a route, so the switch happens, even + * when the error is about the new amount (a limit or insufficient funds). + */ + const fetchWithToFallback = async ( + isCancelled: () => boolean + ): Promise => { + if (toQuoteFallback == null || activeRequest.current !== initialRequest) { + return await fetchQuotes(activeRequest.current) + } + + let toError: Error | undefined + try { + const quotes = await fetchQuotes(initialRequest) + if (quotes.length > 0) return quotes + } catch (error: unknown) { + const currencyError = asMaybeSwapCurrencyError(error) + if (currencyError == null) throw error + toError = currencyError + } + if (isCancelled()) return [] + + const switchToFallback = (): void => { + if (isCancelled()) return + activeRequest.current = toQuoteFallback.swapRequest + toQuoteFallback.onSwitch() + showToast(lstrings.swap_fixed_to_unavailable_toast) + } + + let fromQuotes: EdgeSwapQuote[] + try { + fromQuotes = await fetchQuotes(toQuoteFallback.swapRequest) + } catch (error: unknown) { + if (asMaybeSwapCurrencyError(error) != null) throw toError ?? error + switchToFallback() + throw error + } + if (fromQuotes.length === 0) { + if (toError != null) throw toError + return fromQuotes + } + switchToFallback() + return fromQuotes + } + + const doWork = async (isCancelled: () => boolean): Promise => { + const quotes = await fetchWithToFallback(isCancelled) if (isCancelled()) return if (quotes.length === 0) { // fetchSwapQuotes usually throws when nothing can route, but it resolves @@ -70,6 +147,8 @@ export const SwapProcessingScene: React.FC = (props: Props) => { } const onError = async (error: unknown): Promise => { + const swapRequest = activeRequest.current + // Handle same-address requirement for swap flows requiring a split: const addressError = asMaybeSwapAddressError(error) if (addressError != null && addressError.reason === 'mustMatch') { diff --git a/src/components/themed/SwapInput.tsx b/src/components/themed/SwapInput.tsx index 012546faa43..7ba17705df8 100644 --- a/src/components/themed/SwapInput.tsx +++ b/src/components/themed/SwapInput.tsx @@ -16,9 +16,8 @@ import { useSelector } from '../../types/reactRedux' import { DECIMAL_PRECISION, getDenomFromIsoCode, - maxPrimaryCurrencyConversionDecimals, - precisionAdjust, - removeIsoPrefix + removeIsoPrefix, + roundToFiatPrecision } from '../../util/utils' import { PillButton } from '../buttons/PillButton' import { CryptoIcon } from '../icons/CryptoIcon' @@ -186,32 +185,13 @@ const SwapInputComponent = React.forwardRef( exchangeAmountLong, cryptoExchangeDenom.multiplier ) - const displayAmountLong = div( - nativeAmountLong, - cryptoDisplayDenom.multiplier, - DECIMAL_PRECISION - ) - - const precisionAdjustVal = precisionAdjust({ - primaryExchangeMultiplier: cryptoExchangeDenom.multiplier, - secondaryExchangeMultiplier: fiatDenom.multiplier, - exchangeSecondaryToPrimaryRatio: getExchangeRate( - exchangeRates, - wallet.currencyInfo.pluginId, - tokenId, - defaultIsoFiat - ) + const { displayAmount, nativeAmount } = roundToFiatPrecision({ + nativeAmount: nativeAmountLong, + displayMultiplier: cryptoDisplayDenom.multiplier, + exchangeMultiplier: cryptoExchangeDenom.multiplier, + fiatMultiplier: fiatDenom.multiplier, + exchangeRate }) - const cryptoMaxPrecision = maxPrimaryCurrencyConversionDecimals( - log10(cryptoDisplayDenom.multiplier), - precisionAdjustVal - ) - - // Apply cryptoMaxPrecision to remove extraneous sub-penny precision - const displayAmount = round(displayAmountLong, -cryptoMaxPrecision) - - // Convert back to native and exchange amounts after cryptoMaxPrecision has been applied - const nativeAmount = mul(displayAmount, cryptoDisplayDenom.multiplier) const exchangeAmount = div( nativeAmount, cryptoExchangeDenom.multiplier, @@ -315,7 +295,7 @@ const SwapInputComponent = React.forwardRef( return fiatValue === '0' ? 'crypto' : forceField }, [convertCurrencyHandler, defaultIsoFiat, forceField, tokenId, wallet]) - const renderHeader = (): React.ReactNode => { + const renderHeader = (): React.ReactElement => { return ( {heading} @@ -335,7 +315,7 @@ const SwapInputComponent = React.forwardRef( ) } - const renderFooter = (): React.ReactNode => { + const renderFooter = (): React.ReactElement => { return } diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index f42a645459a..87f32586850 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -615,6 +615,8 @@ const strings = { settings_default_screen_home: 'Home', settings_default_screen_assets: 'Assets', settings_developer_options_force_la: 'Force Light Account Creation', + swap_fixed_to_unavailable_toast: + 'No provider can quote an exact receive amount for this pair. Your send amount is fixed instead, at about the same value.', swap_preferred_header: 'Preferred Exchange', swap_preferred_cheapest: 'Pick best price', swap_preferred_dex: 'Prefer Decentralized', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 910bf57a7a3..078b7e3868d 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -446,6 +446,7 @@ "settings_default_screen_home": "Home", "settings_default_screen_assets": "Assets", "settings_developer_options_force_la": "Force Light Account Creation", + "swap_fixed_to_unavailable_toast": "No provider can quote an exact receive amount for this pair. Your send amount is fixed instead, at about the same value.", "swap_preferred_header": "Preferred Exchange", "swap_preferred_cheapest": "Pick best price", "swap_preferred_dex": "Prefer Decentralized", diff --git a/src/selectors/WalletSelectors.ts b/src/selectors/WalletSelectors.ts index f927e6f86b5..1c28f4adb8b 100644 --- a/src/selectors/WalletSelectors.ts +++ b/src/selectors/WalletSelectors.ts @@ -1,4 +1,4 @@ -import { mul } from 'biggystring' +import { div, mul, toFixed } from 'biggystring' import type { EdgeCurrencyInfo, EdgeCurrencyWallet, @@ -8,7 +8,11 @@ import type { import type { GuiExchangeRates } from '../actions/ExchangeRateActions' import type { RootState } from '../types/reduxTypes' -import { convertNativeToExchange, zeroString } from '../util/utils' +import { + convertNativeToExchange, + DECIMAL_PRECISION, + zeroString +} from '../util/utils' export const getActiveWalletCurrencyInfos = ( currencyWallets: Record @@ -94,6 +98,48 @@ export const convertCurrency = ( return convertedAmount } +/** An asset in a cross-asset conversion. `multiplier` is its exchange denom's. */ +export interface ConvertibleAsset { + pluginId: string + tokenId: EdgeTokenId + multiplier: string +} + +/** + * The native amount of `to` worth the same as `nativeAmount` of `from`, at + * display rates. Returns `undefined` when either asset has no rate, since there + * is nothing to convert through. + */ +export const convertNativeBetweenAssets = ( + exchangeRates: GuiExchangeRates, + isoFiat: string, + nativeAmount: string, + from: ConvertibleAsset, + to: ConvertibleAsset +): string | undefined => { + const fromRate = getExchangeRate( + exchangeRates, + from.pluginId, + from.tokenId, + isoFiat + ) + const toRate = getExchangeRate( + exchangeRates, + to.pluginId, + to.tokenId, + isoFiat + ) + if (fromRate <= 0 || toRate <= 0) return undefined + + const fromExchange = div(nativeAmount, from.multiplier, DECIMAL_PRECISION) + const toExchange = div( + mul(fromExchange, String(fromRate)), + String(toRate), + DECIMAL_PRECISION + ) + return toFixed(mul(toExchange, to.multiplier), 0, 0) +} + export const convertFiatCurrency = ( state: RootState, fromFiatCode: string, diff --git a/src/util/utils.ts b/src/util/utils.ts index ef2d80ac633..0abf4413825 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,4 +1,15 @@ -import { add, div, eq, gt, gte, lt, mul, toFixed } from 'biggystring' +import { + add, + div, + eq, + gt, + gte, + log10, + lt, + mul, + round, + toFixed +} from 'biggystring' import type { EdgeCurrencyConfig, EdgeCurrencyInfo, @@ -494,6 +505,33 @@ export function maxPrimaryCurrencyConversionDecimals( return newPrimaryPrecision >= 0 ? newPrimaryPrecision : 0 } +/** + * Rounds a crypto amount to the decimals that still matter at its fiat rate, + * removing extraneous sub-penny precision. + */ +export function roundToFiatPrecision(params: { + nativeAmount: string + displayMultiplier: string + exchangeMultiplier: string + fiatMultiplier: string + exchangeRate: number +}): { displayAmount: string; nativeAmount: string } { + const { displayMultiplier } = params + const maxDecimals = maxPrimaryCurrencyConversionDecimals( + log10(displayMultiplier), + precisionAdjust({ + primaryExchangeMultiplier: params.exchangeMultiplier, + secondaryExchangeMultiplier: params.fiatMultiplier, + exchangeSecondaryToPrimaryRatio: params.exchangeRate + }) + ) + const displayAmount = round( + div(params.nativeAmount, displayMultiplier, DECIMAL_PRECISION), + -maxDecimals + ) + return { displayAmount, nativeAmount: mul(displayAmount, displayMultiplier) } +} + export const feeStyle = { danger: 'dangerText', warning: 'warningText'