diff --git a/CHANGELOG.md b/CHANGELOG.md index ea74eb21598..88cf5bac427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- changed: The Accelerate Transaction confirmation shows the recipient address and send amount alongside the fees. - fixed: Exchange info, gift card info, Houdini chain, and Zcash migration status refreshes running twice per interval ## 4.53.0 (staging) diff --git a/src/__tests__/modals/AccelerateTxModal.test.tsx b/src/__tests__/modals/AccelerateTxModal.test.tsx index 7624093a1b0..c56b3f7e0e3 100644 --- a/src/__tests__/modals/AccelerateTxModal.test.tsx +++ b/src/__tests__/modals/AccelerateTxModal.test.tsx @@ -1,72 +1,211 @@ import { describe, expect, it } from '@jest/globals' import { render } from '@testing-library/react-native' +import type { + EdgeCurrencyInfo, + EdgeDenomination, + EdgeTokenId, + EdgeTokenMap, + EdgeTransaction +} from 'edge-core-js' import * as React from 'react' +import type { GuiExchangeRates } from '../../actions/ExchangeRateActions' import { AccelerateTxModalComponent } from '../../components/modals/AccelerateTxModal' import { getTheme } from '../../components/services/ThemeContext' import { fakeAirshipBridge } from '../../util/fake/fakeAirshipBridge' import { btcCurrencyInfo } from '../../util/fake/fakeBtcInfo' import { makeFakeCurrencyConfig } from '../../util/fake/fakeCurrencyConfig' +import { ethCurrencyInfo } from '../../util/fake/fakeEthInfo' import { FakeProviders } from '../../util/fake/FakeProviders' +type TxSpendTarget = NonNullable[number] + +const usdcTokens: EdgeTokenMap = { + usdc: { + currencyCode: 'USDC', + displayName: 'USD Coin', + denominations: [{ multiplier: '1000000', name: 'USDC' }], + networkLocation: undefined + } +} + +function makeTx( + currencyCode: string, + tokenId: EdgeTokenId, + nativeAmount: string, + networkFee: string, + spendTargets?: TxSpendTarget[] +): EdgeTransaction { + return { + blockHeight: 0, + currencyCode, + date: 0, + isSend: true, + memos: [], + nativeAmount, + networkFee, + networkFees: [], + otherParams: {}, + ourReceiveAddresses: ['123123123'], + signedTx: '', + spendTargets, + tokenId, + txid: '', + walletId: '' + } +} + +function makeTarget( + currencyCode: string, + nativeAmount: string, + publicAddress: string +): TxSpendTarget { + return { + currencyCode, + nativeAmount, + publicAddress, + memo: undefined, + uniqueIdentifier: undefined + } +} + +function renderModal(opts: { + currencyInfo: EdgeCurrencyInfo + tokens?: EdgeTokenMap + replacedTx: EdgeTransaction + acceleratedTx: EdgeTransaction + amountDisplayDenomination: EdgeDenomination + feeDisplayDenomination: EdgeDenomination + exchangeRates?: GuiExchangeRates +}): ReturnType { + const fakeWallet: any = { + fiatCurrencyCode: 'iso:USD', + currencyInfo: opts.currencyInfo, + currencyConfig: makeFakeCurrencyConfig(opts.currencyInfo, opts.tokens) + } + + return render( + + + + ) +} + +const mbtc: EdgeDenomination = { multiplier: '100000', name: 'mBTC' } +const legacyFixtureDenom: EdgeDenomination = { + multiplier: '1000000', + name: 'BTC' +} + describe('AccelerateTxModalComponent', () => { it('should render with loading props', () => { - const fakeWallet: any = { - addCustomToken: 'shib', - fiatCurrencyCode: 'iso:USD', + const rendered = renderModal({ + currencyInfo: btcCurrencyInfo, + replacedTx: makeTx('BTC', null, '-681', '681'), + acceleratedTx: makeTx('BTC', null, '-95362', '1362', [ + makeTarget('BTC', '94000', 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu') + ]), + amountDisplayDenomination: legacyFixtureDenom, + feeDisplayDenomination: legacyFixtureDenom + }) + + expect(rendered.toJSON()).toMatchSnapshot() + rendered.unmount() + }) + + it('shows each recipient with its own amount', () => { + const rendered = renderModal({ + currencyInfo: btcCurrencyInfo, + replacedTx: makeTx('BTC', null, '-150681', '681'), + acceleratedTx: makeTx('BTC', null, '-151362', '1362', [ + makeTarget('BTC', '100000', 'bc1qfirstrecipient'), + makeTarget('BTC', '50000', 'bc1qsecondrecipient') + ]), + amountDisplayDenomination: mbtc, + feeDisplayDenomination: mbtc + }) + + expect(rendered.getByText('bc1qfirstrecipient')).toBeTruthy() + expect(rendered.getByText(/review the recipient/)).toBeTruthy() + expect(rendered.getByText('bc1qsecondrecipient')).toBeTruthy() + expect(rendered.getByText('1')).toBeTruthy() + expect(rendered.getByText('0.5')).toBeTruthy() + rendered.unmount() + }) + + it('shows fiat values only when there is an exchange rate', () => { + const txs = { currencyInfo: btcCurrencyInfo, - currencyConfig: makeFakeCurrencyConfig(btcCurrencyInfo) + replacedTx: makeTx('BTC', null, '-100681', '681'), + acceleratedTx: makeTx('BTC', null, '-101362', '1362', [ + makeTarget('BTC', '100000', 'bc1qfirstrecipient') + ]), + amountDisplayDenomination: mbtc, + feeDisplayDenomination: mbtc } - const rendered = render( - - - - ) + // Without a rate, neither the amount nor the fees claim a fiat value: + const withoutRate = renderModal(txs) + expect(withoutRate.getByText('1')).toBeTruthy() + expect(withoutRate.queryByText(/\$/)).toBeNull() + withoutRate.unmount() - expect(rendered.toJSON()).toMatchSnapshot() + const btcUsdRate = { current: 50000, yesterday: 50000, expiration: 0 } + const withRate = renderModal({ + ...txs, + exchangeRates: { + crypto: { bitcoin: { '': { 'iso:USD': btcUsdRate } } }, + fiat: {} + } as unknown as GuiExchangeRates + }) + expect(withRate.getByText('1 ($ 50.00)')).toBeTruthy() + withRate.unmount() + }) + + it('shows a token amount in the token, not the parent currency', () => { + const eth: EdgeDenomination = { + multiplier: '1000000000000000000', + name: 'ETH' + } + const rendered = renderModal({ + currencyInfo: ethCurrencyInfo, + tokens: usdcTokens, + replacedTx: makeTx('USDC', 'usdc', '-25000000', '0'), + acceleratedTx: makeTx('USDC', 'usdc', '-25000000', '0', [ + makeTarget('USDC', '25000000', '0xrecipient') + ]), + amountDisplayDenomination: usdcTokens.usdc.denominations[0], + feeDisplayDenomination: eth + }) + + expect(rendered.getByText('25')).toBeTruthy() + rendered.unmount() + }) + + it('omits the recipient rows when the plugin names no spend targets', () => { + const rendered = renderModal({ + currencyInfo: btcCurrencyInfo, + replacedTx: makeTx('BTC', null, '-681', '681'), + acceleratedTx: makeTx('BTC', null, '-1362', '1362'), + amountDisplayDenomination: mbtc, + feeDisplayDenomination: mbtc + }) + + expect(rendered.queryByText('Recipient Address')).toBeNull() + expect(rendered.queryByText('Send Amount')).toBeNull() + // ...and the text does not ask the user to review rows that are absent: + expect(rendered.queryByText(/review the recipient/)).toBeNull() + expect(rendered.getByText(/review new fee amount/)).toBeTruthy() rendered.unmount() }) }) diff --git a/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap index 39ab8dfb6b0..002afb3d75e 100644 --- a/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap @@ -308,187 +308,438 @@ exports[`AccelerateTxModalComponent should render with loading props 1`] = ` ] } > - This will resend your transaction with its fee doubled to help prioritize it for network confirmation. Please review new fee amount before confirming. + This will resend this transaction with its fee doubled to help prioritize it for network confirmation. Please review the recipient, amount, and new fee before confirming. - - + - - Old Fee - - + Recipient Address + + + bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu + + + + +  + + + + + - 0.000681 ($ 0.00) - + + Send Amount + + + 0.094 + + - - - - New Fee - - + Old Fee + + + 0.000681 + + + + + - 0.001362 ($ 0.00) - + + New Fee + + + 0.001362 + + - + { isoFiatCurrencyCode } = this.props - const feeDefaultDenomination = getExchangeDenom(wallet.currencyConfig, null) - const transactionFee = convertTransactionFeeToDisplayFee( - wallet.currencyInfo.pluginId, - null, - isoFiatCurrencyCode, - exchangeRates, - edgeTransaction, - feeDisplayDenomination, - feeDefaultDenomination + return formatAmount( + convertTransactionFeeToDisplayFee( + wallet.currencyInfo.pluginId, + null, + isoFiatCurrencyCode, + exchangeRates, + edgeTransaction, + feeDisplayDenomination, + getExchangeDenom(wallet.currencyConfig, null) + ), + this.hasExchangeRate(null) ) + } - const feeSyntax = `${transactionFee.cryptoSymbol ?? ''} ${ - transactionFee.cryptoAmount - } (${transactionFee.fiatSymbol ?? ''} ${transactionFee.fiatAmount})` + /** + * The amount one spend target receives, in the units and fiat rate of the + * asset being sent (a token send pays out in the token, not the parent + * currency the fee is paid in). + */ + getSendAmountDisplay = (nativeAmount: string): string => { + const { + acceleratedTx, + amountDisplayDenomination, + exchangeRates, + wallet, + isoFiatCurrencyCode + } = this.props + const { tokenId } = acceleratedTx - return feeSyntax + return formatAmount( + convertNativeAmountToDisplayAmount( + wallet.currencyInfo.pluginId, + tokenId, + isoFiatCurrencyCode, + exchangeRates, + nativeAmount, + amountDisplayDenomination, + getExchangeDenom(wallet.currencyConfig, tokenId) + ), + this.hasExchangeRate(tokenId) + ) + } + + /** Whether a fiat value can be shown for one of the wallet's assets */ + hasExchangeRate = (tokenId: EdgeTokenId): boolean => { + const { exchangeRates, wallet, isoFiatCurrencyCode } = this.props + return ( + getExchangeRate( + exchangeRates, + wallet.currencyInfo.pluginId, + tokenId, + isoFiatCurrencyCode + ) !== 0 + ) } render(): React.ReactElement { @@ -136,6 +182,10 @@ export class AccelerateTxModalComponent extends PureComponent { const oldFee = this.getTxFeeDisplay(replacedTx) const newFee = this.getTxFeeDisplay(acceleratedTx) + // Where the accelerated funds go. The plugin names every output of the + // replacement that is not the wallet's own. + const spendTargets = acceleratedTx.spendTargets ?? [] + const isLowerAmount = lt( getTxSendAmount(acceleratedTx), getTxSendAmount(replacedTx) @@ -149,9 +199,29 @@ export class AccelerateTxModalComponent extends PureComponent { title={lstrings.transaction_details_accelerate_transaction_header} > - {lstrings.transaction_details_accelerate_transaction_instructional} + {spendTargets.length > 0 + ? lstrings.transaction_details_accelerate_transaction_instructional_recipient + : lstrings.transaction_details_accelerate_transaction_instructional} - + + {spendTargets.map((target, index) => ( + + + + + ))} { body={newFee} /> )} - + {isLowerAmount ? ( ({ container: { maxHeight: theme.rem(20), marginVertical: theme.rem(0.5) + }, + // The rows scroll once they outgrow their share of the modal, so every + // recipient stays reachable above the slider: + rows: { + flexGrow: 0, + maxHeight: theme.rem(20), + marginVertical: theme.rem(0.5) } })) export function AccelerateTxModal(props: OwnProps): React.ReactElement { const theme = useTheme() + const amountDisplayDenomination = useSelector(state => + selectDisplayDenom( + state, + props.wallet.currencyConfig, + props.acceleratedTx.tokenId + ) + ) const exchangeRates = useSelector(state => state.exchangeRates) const feeDisplayDenomination = useSelector(state => selectDisplayDenom(state, props.wallet.currencyConfig, null) @@ -234,6 +318,7 @@ export function AccelerateTxModal(props: OwnProps): React.ReactElement { return ( ()". The fee rows and the send amount + * rows share it, so they read the same way. Without an exchange rate the fiat + * value would read as zero, which looks like a real amount on a confirmation, + * so it is left out. + */ +function formatAmount( + amount: { + cryptoSymbol?: string + cryptoAmount: string + fiatSymbol?: string + fiatAmount: string + }, + hasExchangeRate: boolean +): string { + const crypto = `${amount.cryptoSymbol ?? ''} ${amount.cryptoAmount}` + if (!hasExchangeRate) return crypto + return `${crypto} (${amount.fiatSymbol ?? ''} ${amount.fiatAmount})` +} + function getTxSendAmount(edgeTransaction: EdgeTransaction): string { // Transaction amounts are negative for send transactions const nativeAmount = abs(edgeTransaction.nativeAmount) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 4ba6461f43d..0fb65e3124b 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1007,6 +1007,11 @@ const strings = { transaction_details_accelerate_transaction_header: 'Accelerate Transaction', transaction_details_accelerate_transaction_instructional: 'This will resend your transaction with its fee doubled to help prioritize it for network confirmation. Please review new fee amount before confirming.', + transaction_details_accelerate_transaction_instructional_recipient: + 'This will resend this transaction with its fee doubled to help prioritize it for network confirmation. Please review the recipient, amount, and new fee before confirming.', + transaction_details_accelerate_transaction_recipient_title: + 'Recipient Address', + transaction_details_accelerate_transaction_amount_title: 'Send Amount', confirm_finish: 'Confirm & Finish', transaction_details_accelerate_transaction_old_fee_title: 'Old Fee', transaction_details_accelerate_transaction_new_fee_title: 'New Fee', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 2263dcb9d22..f95552c5090 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -794,6 +794,9 @@ "transaction_details_advance_details_minertip": "Miner Tip Gwei", "transaction_details_accelerate_transaction_header": "Accelerate Transaction", "transaction_details_accelerate_transaction_instructional": "This will resend your transaction with its fee doubled to help prioritize it for network confirmation. Please review new fee amount before confirming.", + "transaction_details_accelerate_transaction_instructional_recipient": "This will resend this transaction with its fee doubled to help prioritize it for network confirmation. Please review the recipient, amount, and new fee before confirming.", + "transaction_details_accelerate_transaction_recipient_title": "Recipient Address", + "transaction_details_accelerate_transaction_amount_title": "Send Amount", "confirm_finish": "Confirm & Finish", "transaction_details_accelerate_transaction_old_fee_title": "Old Fee", "transaction_details_accelerate_transaction_new_fee_title": "New Fee", diff --git a/src/util/utils.ts b/src/util/utils.ts index 2c38b3068eb..eacf97cf21e 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -502,6 +502,81 @@ export const feeStyle = { warning: 'warningText' } +/** + * Converts a native amount of an asset into its amount in a display + * denomination and its fiat value. + */ +export const convertNativeAmountToDisplayAmount = ( + pluginId: string, + tokenId: EdgeTokenId, + isoFiatCurrencyCode: string, + exchangeRates: GuiExchangeRates, + nativeAmount: string, + displayDenomination: EdgeDenomination, + exchangeDenomination: EdgeDenomination +): { + fiatSymbol: string + fiatAmount: string + cryptoSymbol?: string + cryptoAmount: string + /** The amount in the exchange denomination, for further rate lookups */ + exchangeAmount: string + currencyName?: string +} => { + const secondaryDisplayDenomination = getDenomFromIsoCode(isoFiatCurrencyCode) + const fiatSymbol = + secondaryDisplayDenomination?.symbol ?? + secondaryDisplayDenomination?.name ?? + '' + + if (!gt(nativeAmount, '0')) { + return { + fiatSymbol, + fiatAmount: '0', + cryptoSymbol: displayDenomination?.symbol, + cryptoAmount: '0', + exchangeAmount: '0', + currencyName: displayDenomination?.name + } + } + + const displayMultiplier = + displayDenomination != null ? displayDenomination.multiplier : '' + const exchangeMultiplier = + exchangeDenomination != null ? exchangeDenomination.multiplier : '' + const exchangeDenomAmount = + nativeAmount != null && nativeAmount !== '' + ? convertNativeToDisplay(exchangeMultiplier)(nativeAmount) + : '' + const exchangeToDisplayMultiplierRatio = div( + exchangeMultiplier, + displayMultiplier, + DECIMAL_PRECISION + ) + const cryptoAmount = mul( + exchangeDenomAmount, + exchangeToDisplayMultiplierRatio + ) + const exchangeAmount = + convertNativeToExchange(exchangeMultiplier)(nativeAmount) + const fiatAmount = convertCurrency( + exchangeRates, + pluginId, + tokenId, + isoFiatCurrencyCode, + exchangeAmount + ) + + return { + fiatSymbol, + fiatAmount: toFixed(fiatAmount, FIAT_PRECISION, FIAT_PRECISION), + cryptoSymbol: displayDenomination?.symbol, + cryptoAmount, + exchangeAmount, + currencyName: displayDenomination?.name + } +} + export const convertTransactionFeeToDisplayFee = ( pluginId: string, tokenId: EdgeTokenId, @@ -519,83 +594,54 @@ export const convertTransactionFeeToDisplayFee = ( nativeCryptoAmount: string currencyName?: string } => { - const secondaryDisplayDenomination = getDenomFromIsoCode(isoFiatCurrencyCode) - let feeNativeAmount if (transaction.parentNetworkFee != null) { feeNativeAmount = transaction.parentNetworkFee } else feeNativeAmount = transaction.networkFee + const amount = convertNativeAmountToDisplayAmount( + pluginId, + tokenId, + isoFiatCurrencyCode, + exchangeRates, + feeNativeAmount, + feeDisplayDenomination, + feeDefaultDenomination + ) + if (gt(feeNativeAmount, '0')) { - const cryptoFeeSymbol = feeDisplayDenomination?.symbol ?? '' - const displayMultiplier = - feeDisplayDenomination != null ? feeDisplayDenomination.multiplier : '' - const exchangeMultiplier = - feeDefaultDenomination != null ? feeDefaultDenomination.multiplier : '' - const cryptoFeeExchangeDenomAmount = - feeNativeAmount != null && feeNativeAmount !== '' - ? convertNativeToDisplay(exchangeMultiplier)(feeNativeAmount) - : '' - const exchangeToDisplayMultiplierRatio = div( - exchangeMultiplier, - displayMultiplier, - DECIMAL_PRECISION - ) - const cryptoAmount = mul( - cryptoFeeExchangeDenomAmount, - exchangeToDisplayMultiplierRatio - ) - const cryptoFeeExchangeAmount = - convertNativeToExchange(exchangeMultiplier)(feeNativeAmount) - const fiatFeeAmount = convertCurrency( - exchangeRates, - pluginId, - tokenId, - isoFiatCurrencyCode, - cryptoFeeExchangeAmount - ) const feeAmountInUSD = convertCurrency( exchangeRates, pluginId, tokenId, 'iso:USD', - cryptoFeeExchangeAmount + amount.exchangeAmount ) - const fiatAmount = { - amount: toFixed(fiatFeeAmount, FIAT_PRECISION, FIAT_PRECISION), - style: + + return { + fiatSymbol: amount.fiatSymbol, + fiatAmount: amount.fiatAmount, + fiatStyle: parseFloat(feeAmountInUSD) > FEE_ALERT_THRESHOLD ? feeStyle.danger : parseFloat(feeAmountInUSD) > FEE_COLOR_THRESHOLD ? feeStyle.warning - : undefined - } - - return { - fiatSymbol: - secondaryDisplayDenomination?.symbol ?? - secondaryDisplayDenomination?.name ?? - '', - fiatAmount: fiatAmount.amount, - fiatStyle: fiatAmount.style, - cryptoSymbol: cryptoFeeSymbol, - cryptoAmount, + : undefined, + cryptoSymbol: amount.cryptoSymbol ?? '', + cryptoAmount: amount.cryptoAmount, nativeCryptoAmount: feeNativeAmount, - currencyName: feeDisplayDenomination?.name + currencyName: amount.currencyName } } return { - fiatSymbol: - secondaryDisplayDenomination?.symbol ?? - secondaryDisplayDenomination?.name ?? - '', + fiatSymbol: amount.fiatSymbol, fiatAmount: '0', fiatStyle: undefined, - cryptoSymbol: feeDisplayDenomination?.symbol, + cryptoSymbol: amount.cryptoSymbol, cryptoAmount: '0', nativeCryptoAmount: '0', - currencyName: feeDisplayDenomination?.name + currencyName: amount.currencyName } }