diff --git a/CHANGELOG.md b/CHANGELOG.md index 709a3d15553..82fc0ca8ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased (develop) - added: `chooseCaip19Asset` EdgeProvider API for precise wallet selection using CAIP-19 identifiers +- added: Integrated reports server order status to transaction details. - changed: Append chain names to token codes in RampCreateScene ## 4.42.0 (staging) diff --git a/eslint.config.mjs b/eslint.config.mjs index 2bbc3a7d65e..5fdf223937c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -129,7 +129,6 @@ export default [ 'src/components/cards/StakingOptionCard.tsx', 'src/components/cards/StakingReturnsCard.tsx', 'src/components/cards/SupportCard.tsx', - 'src/components/cards/SwapDetailsCard.tsx', 'src/components/cards/TappableAccountCard.tsx', 'src/components/cards/TappableCard.tsx', 'src/components/cards/UnderlinedNumInputCard.tsx', @@ -230,8 +229,6 @@ export default [ 'src/components/rows/CryptoFiatAmountRow.tsx', 'src/components/rows/CurrencyRow.tsx', - 'src/components/rows/EdgeRow.tsx', - 'src/components/rows/PaymentMethodRow.tsx', 'src/components/rows/SwapProviderRow.tsx', 'src/components/rows/TxCryptoAmountRow.tsx', @@ -308,8 +305,6 @@ export default [ 'src/components/scenes/SweepPrivateKeyCompletionScene.tsx', 'src/components/scenes/SweepPrivateKeyProcessingScene.tsx', - 'src/components/scenes/TransactionDetailsScene.tsx', - 'src/components/scenes/TransactionsExportScene.tsx', 'src/components/scenes/UpgradeUsernameScreen.tsx', @@ -318,7 +313,7 @@ export default [ 'src/components/scenes/WcConnectScene.tsx', 'src/components/scenes/WcDisconnectScene.tsx', 'src/components/scenes/WebViewScene.tsx', - 'src/components/services/AccountCallbackManager.tsx', + 'src/components/services/ActionQueueService.ts', 'src/components/services/AirshipInstance.tsx', 'src/components/services/AutoLogout.ts', diff --git a/src/__tests__/components/__snapshots__/Row.test.tsx.snap b/src/__tests__/components/__snapshots__/Row.test.tsx.snap index 0cbdda861d3..282aef143b0 100644 --- a/src/__tests__/components/__snapshots__/Row.test.tsx.snap +++ b/src/__tests__/components/__snapshots__/Row.test.tsx.snap @@ -391,7 +391,7 @@ exports[`RowUi4 renders correctly with flex: 1 children 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -525,7 +525,7 @@ exports[`RowUi4 renders correctly with flex: 1 children 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -659,7 +659,7 @@ exports[`RowUi4 renders correctly with flex: 1 children 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -793,7 +793,7 @@ exports[`RowUi4 renders correctly with flex: 1 children 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -927,7 +927,7 @@ exports[`RowUi4 renders correctly with flex: 1 children 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1262,7 +1262,7 @@ exports[`RowUi4 renders correctly with multiple rows in a flex: 1 View 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1412,7 +1412,7 @@ exports[`RowUi4 renders correctly with multiple rows in a flex: 1 View 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1562,7 +1562,7 @@ exports[`RowUi4 renders correctly with multiple rows in a flex: 1 View 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1719,7 +1719,7 @@ exports[`RowUi4 renders correctly with multiple rows in a flex: 1 View 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1872,7 +1872,7 @@ exports[`RowUi4 should handle press events 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2243,7 +2243,7 @@ exports[`RowUi4 should render a row with a right button 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2392,7 +2392,7 @@ exports[`RowUi4 should render a row with a right button 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2541,7 +2541,7 @@ exports[`RowUi4 should render a row with a right button 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2697,7 +2697,7 @@ exports[`RowUi4 should render a row with a right button 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/src/__tests__/modals/__snapshots__/AdvancedDetailsCard.test.tsx.snap b/src/__tests__/modals/__snapshots__/AdvancedDetailsCard.test.tsx.snap index 76aaf0cc2ed..4f8ccc2809d 100644 --- a/src/__tests__/modals/__snapshots__/AdvancedDetailsCard.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/AdvancedDetailsCard.test.tsx.snap @@ -139,7 +139,7 @@ exports[`AdvancedDetailsCard should render with loading props 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/src/__tests__/scenes/__snapshots__/FioAddressListScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/FioAddressListScene.test.tsx.snap index 81897f61bd9..b4b61877d89 100644 --- a/src/__tests__/scenes/__snapshots__/FioAddressListScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/FioAddressListScene.test.tsx.snap @@ -496,7 +496,7 @@ exports[`FioAddressList should render with loading props 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -824,7 +824,7 @@ exports[`FioAddressList should render with loading props 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/src/__tests__/scenes/__snapshots__/FioAddressRegisterScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/FioAddressRegisterScene.test.tsx.snap index 0c712dbe809..4afd8d51975 100644 --- a/src/__tests__/scenes/__snapshots__/FioAddressRegisterScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/FioAddressRegisterScene.test.tsx.snap @@ -633,7 +633,7 @@ exports[`FioAddressRegister should render with loading props 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -815,7 +815,7 @@ exports[`FioAddressRegister should render with loading props 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/src/__tests__/scenes/__snapshots__/FioDomainRegisterScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/FioDomainRegisterScene.test.tsx.snap index f22e469e412..99d3fde1378 100644 --- a/src/__tests__/scenes/__snapshots__/FioDomainRegisterScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/FioDomainRegisterScene.test.tsx.snap @@ -599,7 +599,7 @@ exports[`FioDomainRegister should render with loading props 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap index 4ff78549a01..d4ab91d2182 100644 --- a/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap @@ -1144,7 +1144,7 @@ exports[`Request should render with loaded props 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap index 6db59a4cfba..156f01fd43a 100644 --- a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap @@ -388,7 +388,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -919,7 +919,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1138,7 +1138,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1385,7 +1385,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2142,7 +2142,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2673,7 +2673,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2892,7 +2892,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -3139,7 +3139,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -4128,7 +4128,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -4412,7 +4412,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -4884,7 +4884,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -5103,7 +5103,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -5350,7 +5350,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -6107,7 +6107,7 @@ exports[`SendScene2 2 spendTargets hide tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -6391,7 +6391,7 @@ exports[`SendScene2 2 spendTargets hide tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -6637,7 +6637,7 @@ exports[`SendScene2 2 spendTargets hide tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -6917,7 +6917,7 @@ exports[`SendScene2 2 spendTargets hide tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -7674,7 +7674,7 @@ exports[`SendScene2 2 spendTargets hide tiles 2`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -7958,7 +7958,7 @@ exports[`SendScene2 2 spendTargets hide tiles 2`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -8464,7 +8464,7 @@ exports[`SendScene2 2 spendTargets hide tiles 2`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -9221,7 +9221,7 @@ exports[`SendScene2 2 spendTargets hide tiles 3`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -9505,7 +9505,7 @@ exports[`SendScene2 2 spendTargets hide tiles 3`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -9785,7 +9785,7 @@ exports[`SendScene2 2 spendTargets hide tiles 3`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -10542,7 +10542,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -10826,7 +10826,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -11232,7 +11232,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -11512,7 +11512,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -12269,7 +12269,7 @@ exports[`SendScene2 2 spendTargets lock tiles 2`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -12471,7 +12471,7 @@ exports[`SendScene2 2 spendTargets lock tiles 2`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -13197,7 +13197,7 @@ exports[`SendScene2 2 spendTargets lock tiles 2`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -13954,7 +13954,7 @@ exports[`SendScene2 2 spendTargets lock tiles 3`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -14156,7 +14156,7 @@ exports[`SendScene2 2 spendTargets lock tiles 3`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -14816,7 +14816,7 @@ exports[`SendScene2 2 spendTargets lock tiles 3`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -15573,7 +15573,7 @@ exports[`SendScene2 Render SendScene 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap index 1bcef38aaf2..8a8d277577e 100644 --- a/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap @@ -428,7 +428,7 @@ exports[`TransactionDetailsScene should render 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -849,7 +849,7 @@ exports[`TransactionDetailsScene should render 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1486,7 +1486,7 @@ exports[`TransactionDetailsScene should render 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1668,7 +1668,7 @@ exports[`TransactionDetailsScene should render 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -1988,7 +1988,7 @@ exports[`TransactionDetailsScene should render 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2230,7 +2230,7 @@ exports[`TransactionDetailsScene should render 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2419,7 +2419,7 @@ exports[`TransactionDetailsScene should render 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2601,7 +2601,7 @@ exports[`TransactionDetailsScene should render 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -3355,7 +3355,7 @@ exports[`TransactionDetailsScene should render with negative nativeAmount and fi { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -3776,7 +3776,7 @@ exports[`TransactionDetailsScene should render with negative nativeAmount and fi { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -4413,7 +4413,7 @@ exports[`TransactionDetailsScene should render with negative nativeAmount and fi { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -4595,7 +4595,7 @@ exports[`TransactionDetailsScene should render with negative nativeAmount and fi { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -4915,7 +4915,7 @@ exports[`TransactionDetailsScene should render with negative nativeAmount and fi { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -5157,7 +5157,7 @@ exports[`TransactionDetailsScene should render with negative nativeAmount and fi { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -5346,7 +5346,7 @@ exports[`TransactionDetailsScene should render with negative nativeAmount and fi { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -5528,7 +5528,7 @@ exports[`TransactionDetailsScene should render with negative nativeAmount and fi { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/src/components/cards/SwapDetailsCard.tsx b/src/components/cards/SwapDetailsCard.tsx index a0df0c33f4a..1955c2f32bc 100644 --- a/src/components/cards/SwapDetailsCard.tsx +++ b/src/components/cards/SwapDetailsCard.tsx @@ -5,13 +5,12 @@ import type { EdgeTxSwap } from 'edge-core-js' import * as React from 'react' -import { Linking, Platform, View } from 'react-native' +import { Linking, Platform } from 'react-native' import Mailer from 'react-native-mail' import SafariView from 'react-native-safari-view' import { sprintf } from 'sprintf-js' import { useHandler } from '../../hooks/useHandler' -import { useWalletName } from '../../hooks/useWalletName' import { useWatch } from '../../hooks/useWatch' import { lstrings } from '../../locales/strings' import { @@ -21,18 +20,28 @@ import { import { useSelector } from '../../types/reactRedux' import { getTokenId } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' +import type { ReportsTxInfo } from '../../util/reportsServer' import { convertNativeToDisplay, unixToLocaleDateTime } from '../../util/utils' -import { RawTextModal } from '../modals/RawTextModal' +import { DataSheetModal, type DataSheetSection } from '../modals/DataSheetModal' +import { ShimmerText } from '../progress-indicators/ShimmerText' import { EdgeRow } from '../rows/EdgeRow' import { Airship, showError } from '../services/AirshipInstance' -import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' import { EdgeCard } from './EdgeCard' interface Props { swapData: EdgeTxSwap transaction: EdgeTransaction - wallet: EdgeCurrencyWallet + sourceWallet?: EdgeCurrencyWallet + + /** The transaction info from the reports server. */ + reportsTxInfo?: ReportsTxInfo + + /** + * Whether the transaction info from the reports server is loading. + * If not provided, the card will not show the status. + * */ + isReportsTxInfoLoading?: boolean } const TXID_PLACEHOLDER = '{{TXID}}' @@ -40,9 +49,10 @@ const TXID_PLACEHOLDER = '{{TXID}}' // Metadata may have been created and saved before tokenId was required. // If tokenId is missing it defaults to null so we can try upgrading it. const upgradeSwapData = ( - destinationWallet: EdgeCurrencyWallet, + destinationWallet: EdgeCurrencyWallet | undefined, swapData: EdgeTxSwap ): EdgeTxSwap => { + if (destinationWallet == null) return swapData if ( swapData.payoutTokenId === undefined && destinationWallet.currencyInfo.currencyCode !== swapData.payoutCurrencyCode @@ -58,63 +68,54 @@ const upgradeSwapData = ( return swapData } -export function SwapDetailsCard(props: Props) { - const { swapData, transaction, wallet } = props - const theme = useTheme() - const styles = getStyles(theme) - +export const SwapDetailsCard: React.FC = (props: Props) => { + const { + transaction, + sourceWallet, + reportsTxInfo, + isReportsTxInfoLoading = false + } = props const { memos = [], spendTargets = [], tokenId } = transaction - const { currencyInfo } = wallet - const walletName = useWalletName(wallet) - const walletDefaultDenom = useSelector(state => - transaction.tokenId === null - ? getExchangeDenom(wallet.currencyConfig, tokenId) - : selectDisplayDenom(state, wallet.currencyConfig, tokenId) - ) - - // The wallet may have been deleted: - const account = useSelector(state => state.core.account) - const currencyWallets = useWatch(account, 'currencyWallets') - const destinationWallet = currencyWallets[swapData.payoutWalletId] - const destinationWalletName = - destinationWallet == null ? '' : getWalletName(destinationWallet) - const { - isEstimate, - orderId, - orderUri, - payoutAddress, - payoutCurrencyCode, - payoutTokenId, - plugin, - refundAddress - } = upgradeSwapData(wallet, swapData) + const swapData = upgradeSwapData(sourceWallet, props.swapData) const formattedOrderUri = - orderUri == null + swapData.orderUri == null ? undefined - : orderUri.replace(TXID_PLACEHOLDER, transaction.txid) + : swapData.orderUri.replace(TXID_PLACEHOLDER, transaction.txid) const handleExchangeDetails = useHandler(async () => { await Airship.show(bridge => ( - )) }) const handleEmail = useHandler(() => { - const body = createExchangeDataString('
') + // Serialize the data sheet sections to a string: + const sections = createExchangeDataSheetSections() + const body = sections + .map(section => + // Separate rows with a newline + section.rows.map(row => row.title + ': ' + row.body).join('\n') + ) + // Separate sections with two newlines + .join('\n\n') + // Replace newlines with
tags + .replaceAll('\n', '
') Mailer.mail( { subject: sprintf( lstrings.transaction_details_exchange_support_request, - plugin.displayName + swapData.plugin.displayName ), recipients: - plugin.supportEmail != null ? [plugin.supportEmail] : undefined, + swapData.plugin.supportEmail != null + ? [swapData.plugin.supportEmail] + : undefined, body, isHTML: true }, @@ -124,12 +125,12 @@ export function SwapDetailsCard(props: Props) { return } - if (error) showError(error) + if (error != null) showError(error) } ) }) - const handleLink = async () => { + const handleLink = async (): Promise => { if (formattedOrderUri == null) return // Replace {{TXID}} with actual transaction ID if present @@ -140,9 +141,9 @@ export function SwapDetailsCard(props: Props) { if (available) await SafariView.show({ url: formattedOrderUri }) else await Linking.openURL(formattedOrderUri) }) - .catch(error => { + .catch((error: unknown) => { showError(error) - Linking.openURL(formattedOrderUri).catch(err => { + Linking.openURL(formattedOrderUri).catch((err: unknown) => { showError(err) }) }) @@ -151,89 +152,173 @@ export function SwapDetailsCard(props: Props) { } } + // The wallet may have been deleted: + const account = useSelector(state => state.core.account) + const currencyWallets = useWatch(account, 'currencyWallets') + const destinationWallet = currencyWallets[swapData.payoutWalletId] + const destinationWalletName = + destinationWallet == null ? '' : getWalletName(destinationWallet) const destinationDenomination = useSelector(state => - destinationWallet == null || payoutTokenId === undefined + destinationWallet == null || swapData.payoutTokenId === undefined ? undefined : selectDisplayDenom( state, destinationWallet.currencyConfig, - payoutTokenId + swapData.payoutTokenId ) ) - if (destinationDenomination == null) return null const sourceNativeAmount = sub( abs(transaction.nativeAmount), transaction.networkFee ) - const sourceAmount = convertNativeToDisplay(walletDefaultDenom.multiplier)( - sourceNativeAmount + const sourceWalletDenom = useSelector(state => + sourceWallet?.currencyInfo.currencyCode === transaction.currencyCode + ? getExchangeDenom(sourceWallet.currencyConfig, tokenId) + : sourceWallet != null + ? selectDisplayDenom(state, sourceWallet.currencyConfig, tokenId) + : undefined ) + const sourceAmount = + sourceWalletDenom == null + ? undefined + : convertNativeToDisplay(sourceWalletDenom.multiplier)(sourceNativeAmount) const sourceAssetName = - tokenId == null - ? walletDefaultDenom.name - : `${walletDefaultDenom.name} (${ - getExchangeDenom(wallet.currencyConfig, null).name + sourceWalletDenom == null || sourceWallet == null + ? undefined + : tokenId == null + ? sourceWalletDenom.name + : `${sourceWalletDenom.name} (${ + getExchangeDenom(sourceWallet.currencyConfig, null).name })` - const destinationAmount = convertNativeToDisplay( - destinationDenomination.multiplier - )(swapData.payoutNativeAmount) + const destinationAmount = + destinationDenomination == null + ? undefined + : convertNativeToDisplay(destinationDenomination.multiplier)( + swapData.payoutNativeAmount + ) const destinationAssetName = - payoutTokenId == null - ? payoutCurrencyCode - : `${payoutCurrencyCode} (${ + swapData.payoutTokenId == null + ? swapData.payoutCurrencyCode + : `${swapData.payoutCurrencyCode} (${ getExchangeDenom(destinationWallet.currencyConfig, null).name })` - const symbolString = - currencyInfo.currencyCode === transaction.currencyCode && - walletDefaultDenom.symbol != null - ? walletDefaultDenom.symbol - : transaction.currencyCode - - const createExchangeDataString = (newline: string = '\n') => { + const createExchangeDataSheetSections = (): DataSheetSection[] => { const uniqueIdentifier = memos .map( (memo, index) => - `${memo.value}${index + 1 !== memos.length ? newline : ''}` + `${memo.value}${index + 1 !== memos.length ? '\n' : ''}` ) .toString() const exchangeAddresses = spendTargets .map( (target, index) => `${target.publicAddress}${ - index + 1 !== spendTargets.length ? newline : '' + index + 1 !== spendTargets.length ? '\n' : '' }` ) .toString() const { dateTime } = unixToLocaleDateTime(transaction.date) - return `${lstrings.fio_date_label}: ${dateTime}${newline}${ - lstrings.transaction_details_exchange_service - }: ${plugin.displayName}${newline}${ - lstrings.transaction_details_exchange_order_id - }: ${orderId ?? ''}${newline}${ - lstrings.transaction_details_exchange_source_wallet - }: ${walletName}${newline}${ - lstrings.fragment_send_from_label - }: ${sourceAmount} ${sourceAssetName}${newline}${ - lstrings.string_to_capitalize - }: ${destinationAmount} ${destinationAssetName}${newline}${ - lstrings.transaction_details_exchange_destination_wallet - }: ${destinationWalletName}${newline}${ - isEstimate ? lstrings.estimated_quote : lstrings.fixed_quote - }${newline}${newline}${lstrings.transaction_details_tx_id_modal_title}: ${ - transaction.txid - }${newline}${newline}${ - lstrings.transaction_details_exchange_exchange_address - }:${newline}${exchangeAddresses}${newline}${newline}${ - lstrings.transaction_details_exchange_exchange_unique_id - }:${newline}${uniqueIdentifier}${newline}${newline}${ - lstrings.transaction_details_exchange_payout_address - }:${newline}${payoutAddress}${newline}${newline}${ - lstrings.transaction_details_exchange_refund_address - }:${newline}${refundAddress ?? ''}${newline}` + return [ + { + rows: [ + { + title: lstrings.fio_date_label, + body: dateTime + }, + { + title: lstrings.transaction_details_exchange_service, + body: swapData.plugin.displayName + }, + { + title: lstrings.transaction_details_exchange_order_id, + body: swapData.orderId ?? '' + }, + { + title: lstrings.quote_type, + body: swapData.isEstimate + ? lstrings.estimated_quote + : lstrings.fixed_quote + }, + ...(reportsTxInfo == null + ? [] + : [ + { + title: lstrings.transaction_details_exchange_status, + body: reportsTxInfo.swapInfo.status + } + ]) + ] + }, + { + rows: [ + ...(sourceWallet?.name == null + ? [] + : [ + { + title: lstrings.transaction_details_exchange_source_wallet, + body: sourceWallet.name + } + ]), + ...(sourceAmount == null || sourceAssetName == null + ? [] + : [ + { + title: lstrings.string_send_amount, + body: `${sourceAmount} ${sourceAssetName}` + } + ]) + ] + }, + { + rows: [ + { + title: lstrings.transaction_details_exchange_destination_wallet, + body: destinationWalletName + }, + { + title: lstrings.string_receive_amount, + body: `${destinationAmount} ${destinationAssetName}` + } + ] + }, + { + rows: [ + { + title: lstrings.transaction_details_tx_id_modal_title, + body: transaction.txid + }, + { + title: lstrings.transaction_details_exchange_exchange_address, + body: exchangeAddresses + }, + ...(uniqueIdentifier !== '' + ? [ + { + title: + lstrings.transaction_details_exchange_exchange_unique_id, + body: uniqueIdentifier + } + ] + : []), + { + title: lstrings.transaction_details_exchange_payout_address, + body: swapData.payoutAddress + }, + { + title: lstrings.transaction_details_exchange_refund_address, + body: swapData.refundAddress ?? '' + } + ] + } + ] + } + + if (destinationAmount == null) { + return null } return ( @@ -243,25 +328,31 @@ export function SwapDetailsCard(props: Props) { title={lstrings.transaction_details_exchange_details} onPress={handleExchangeDetails} > - - - {lstrings.title_exchange + ' ' + sourceAmount + ' ' + symbolString} - - - {lstrings.string_to_capitalize + - ' ' + - destinationAmount + - ' ' + - destinationAssetName} - - - {swapData.isEstimate - ? lstrings.estimated_quote - : lstrings.fixed_quote} - - + + {(sourceAmount == null ? '' : `${sourceAmount} ${sourceAssetName}`) + + ' → ' + + `${destinationAmount} ${destinationAssetName}`} + + + {swapData.isEstimate + ? lstrings.estimated_quote + : lstrings.fixed_quote} + - {orderUri == null ? null : ( + {isReportsTxInfoLoading == null ? null : ( + + {isReportsTxInfoLoading ? ( + + ) : ( + + {reportsTxInfo == null + ? lstrings.string_unknown + : reportsTxInfo.swapInfo.status} + + )} + + )} + {swapData.orderUri == null ? null : ( )} - {plugin.supportEmail == null ? null : ( + {swapData.plugin.supportEmail == null ? null : ( ) } - -const getStyles = cacheStyles((theme: Theme) => ({ - tileColumn: { - flexDirection: 'column', - justifyContent: 'center' - } -})) diff --git a/src/components/modals/DataSheetModal.tsx b/src/components/modals/DataSheetModal.tsx new file mode 100644 index 00000000000..e841299c63a --- /dev/null +++ b/src/components/modals/DataSheetModal.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import { Fragment } from 'react' +import { ScrollView } from 'react-native' +import type { AirshipBridge } from 'react-native-airship' + +import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' +import { EdgeCard } from '../cards/EdgeCard' +import { SectionHeader } from '../common/SectionHeader' +import { EdgeRow } from '../rows/EdgeRow' +import { EdgeModal } from './EdgeModal' + +interface Props { + bridge: AirshipBridge + + /** The sections data to display in the modal. */ + sections: DataSheetSection[] + + /** The title of the modal. */ + title?: string +} + +export interface DataSheetSection { + /** The title of the section. */ + title?: string + + /** The rows of the section. */ + rows: DataSheetRow[] +} + +export interface DataSheetRow { + /** The title or label of the row. */ + title: string + + /** The body text of the row. */ + body: string +} + +export const DataSheetModal: React.FC = (props: Props) => { + const { bridge, sections, title } = props + + const handleCancel = (): void => { + bridge.resolve(undefined) + } + + return ( + + + {sections.map((section, index) => ( + + {section.title == null ? null : ( + + )} + + {section.rows.map((row, index) => ( + + ))} + + + ))} + + + ) +} diff --git a/src/components/progress-indicators/ShimmerText.tsx b/src/components/progress-indicators/ShimmerText.tsx new file mode 100644 index 00000000000..7450c70a4ee --- /dev/null +++ b/src/components/progress-indicators/ShimmerText.tsx @@ -0,0 +1,137 @@ +import * as React from 'react' +import { type DimensionValue, View } from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import Animated, { + type SharedValue, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming +} from 'react-native-reanimated' + +import { useHandler } from '../../hooks/useHandler' +import { useLayout } from '../../hooks/useLayout' +import { styled } from '../hoc/styled' +import { useTheme } from '../services/ThemeContext' + +interface Props { + /** Whether the component is shown (rendered). Default: true */ + isShown?: boolean + + /** Number of characters to represent in size for the shimmer. */ + characters?: number + + /** Number of lines to represent in size for the shimmer. */ + lines?: number +} + +export const ShimmerText: React.FC = (props: Props) => { + const { isShown = true, characters, lines = 1 } = props + const theme = useTheme() + + const containerHeight: DimensionValue = React.useMemo( + () => theme.rem(lines * 1.5), + [lines, theme] + ) + const containerWidth: DimensionValue = React.useMemo( + () => + characters != null + ? characters * theme.rem(0.75) + : ((Math.floor(Math.random() * 80) + 20 + '%') as DimensionValue), + [characters, theme] + ) + + const [containerLayout, handleContainerLayout] = useLayout() + const containerLayoutWidth = containerLayout.width + const gradientWidth = containerLayoutWidth * 6 + + const offset = useSharedValue(0) + + const startAnimation = useHandler(() => { + const duration = 2000 + const startPosition = -gradientWidth + const endPosition = containerLayoutWidth + offset.value = startPosition + offset.value = withRepeat( + withSequence( + withTiming(startPosition, { duration: duration / 2 }), + withTiming(endPosition, { duration }) + ), + -1, + false + ) + }) + + React.useEffect(() => { + if (gradientWidth > 0) startAnimation() + }, [startAnimation, gradientWidth]) + + return isShown ? ( + + + + + + + ) : null +} + +/** + * This is the track of the component that contains a gradient that overflows + * in width. + */ +const ContainerView = styled(View)<{ + width: DimensionValue + height: DimensionValue +}>(theme => props => ({ + width: props.width, + maxWidth: '100%', + height: props.height, + borderRadius: theme.rem(0.25), + backgroundColor: theme.shimmerBackgroundColor, + overflow: 'hidden' +})) + +/** + * This is the animated view that within the {@link ContainerView}. It animates + * by an offset value which represents the horizontal position of the shimmer. + */ +const Shimmer = styled(Animated.View)<{ + width: DimensionValue + offset: SharedValue +}>(_ => props => [ + { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + display: 'flex', + flexDirection: 'row', + width: props.width + }, + useAnimatedStyle(() => ({ + transform: [{ translateX: props.offset.value }] + })) +]) + +/** + * This is gradient nested within the {@link Shimmer}. + */ +const Gradient = styled(LinearGradient)({ + flex: 1, + width: '100%', + height: '100%' +}) diff --git a/src/components/rows/EdgeRow.tsx b/src/components/rows/EdgeRow.tsx index ba39bb26ef8..8ff95d7bccc 100644 --- a/src/components/rows/EdgeRow.tsx +++ b/src/components/rows/EdgeRow.tsx @@ -15,6 +15,7 @@ import { triggerHaptic } from '../../util/haptic' import { fixSides, mapSides, sidesToMargin } from '../../util/sides' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { ChevronRightIcon } from '../icons/ThemedIcons' +import { ShimmerText } from '../progress-indicators/ShimmerText' import { showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' @@ -39,6 +40,7 @@ interface Props { error?: boolean icon?: React.ReactNode loading?: boolean + shimmer?: boolean maximumHeight?: 'small' | 'medium' | 'large' rightButtonType?: RowActionIcon title?: string @@ -50,13 +52,14 @@ interface Props { marginRem?: number[] | number } -export const EdgeRow = (props: Props) => { +export const EdgeRow: React.FC = (props: Props) => { const { body, children, error, icon, - loading, + loading = false, + shimmer = false, marginRem, maximumHeight = 'medium', testID, @@ -118,12 +121,14 @@ export const EdgeRow = (props: Props) => { {title == null ? null : ( {title} )} - {loading ? ( + {shimmer ? ( + + ) : loading ? ( = props => { const styles = getStyles(theme) const iconColor = useIconColor({ pluginId: currencyInfo.pluginId, tokenId }) + const transactionSwapData = (): EdgeTxSwap | undefined => + convertActionToSwapData(account, transaction) ?? transaction.swapData + + // Query for transaction info from reports server only if the transaction is + // a receive (we need to get potential swap data) or the transaction has + // swap data (we need to get the status) + const shouldShowTradeDetails = + !transaction.isSend || transactionSwapData() != null + const { data: reportsTxInfo, isLoading: isReportsTxInfoLoading } = useQuery({ + queryKey: ['txInfo', transaction.txid], + queryFn: async () => { + return await queryReportsTxInfo(wallet, transaction) + }, + staleTime: query => + // Only cache if the status has resolved, otherwise we'll always consider + // the data to be stale: + ['processing', 'pending', undefined].includes( + query.state.data?.swapInfo.status + ) + ? 0 // No cache + : Infinity, // Cache forever + enabled: shouldShowTradeDetails, + retry: false + }) + + const swapDataFromReports = useMemo( + () => + reportsTxInfo == null + ? undefined + : toEdgeTxSwap(account, wallet, transaction, reportsTxInfo), + [account, reportsTxInfo, transaction, wallet] + ) + + const edgeTxActionSwapFromReports = useMemo(() => { + if (reportsTxInfo == null) return + return toEdgeTxActionSwap(account, transaction, reportsTxInfo) + }, [account, reportsTxInfo, transaction]) + + // Update the transaction object with saveAction data from reports server: + if ( + edgeTxActionSwapFromReports != null && + transaction.savedAction !== edgeTxActionSwapFromReports + ) { + transaction.savedAction = edgeTxActionSwapFromReports + transaction.assetAction = { + assetActionType: 'swap' + } + } + // Choose a default category based on metadata or the txAction const { action, @@ -96,8 +152,7 @@ export const TransactionDetailsComponent: React.FC = props => { savedData } = getTxActionDisplayInfo(transaction, account, wallet) - const swapData = - convertActionToSwapData(account, transaction) ?? transaction.swapData + const swapData = transactionSwapData() ?? swapDataFromReports const thumbnailPath = useContactThumbnail(mergedData.name) ?? pluginIdIcons[iconPluginId ?? ''] @@ -610,7 +665,11 @@ export const TransactionDetailsComponent: React.FC = props => { )} diff --git a/src/components/services/AccountCallbackManager.tsx b/src/components/services/AccountCallbackManager.tsx index 170416e3ef6..9eef9e3b14b 100644 --- a/src/components/services/AccountCallbackManager.tsx +++ b/src/components/services/AccountCallbackManager.tsx @@ -23,6 +23,7 @@ import { convertCurrency } from '../../selectors/WalletSelectors' import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' import { makePeriodicTask } from '../../util/PeriodicTask' +import { mergeReportsTxInfo } from '../../util/reportsServer' import { convertNativeToExchange, datelog, snooze } from '../../util/utils' import { Airship, showDevError } from './AirshipInstance' @@ -44,7 +45,7 @@ const notDirty: DirtyList = { walletList: false } -export function AccountCallbackManager(props: Props) { +export const AccountCallbackManager: React.FC = (props: Props) => { const { account, navigation } = props const dispatch = useDispatch() const exchangeRates = useSelector(state => state.exchangeRates) @@ -52,7 +53,7 @@ export function AccountCallbackManager(props: Props) { const numWallets = React.useRef(0) // Helper for marking wallets dirty: - function setRatesDirty() { + function setRatesDirty(): void { setDirty(dirty => ({ ...dirty, rates: true @@ -109,7 +110,7 @@ export function AccountCallbackManager(props: Props) { cacheEntries.forEach(cacheEntry => { const { currencyCode, metadata } = cacheEntry if (tx.currencyCode !== currencyCode) return - wallet.saveTx({ ...tx, metadata }).catch(err => { + wallet.saveTx({ ...tx, metadata }).catch((err: unknown) => { console.warn(err) }) }) @@ -127,9 +128,22 @@ export function AccountCallbackManager(props: Props) { // Check for incoming FIO requests: const receivedTxs = transactions.filter(tx => !tx.isSend) if (receivedTxs.length > 0) - dispatch(checkFioObtData(wallet, receivedTxs)).catch(err => { - console.warn(err) - }) + dispatch(checkFioObtData(wallet, receivedTxs)).catch( + (err: unknown) => { + console.warn(err) + } + ) + + const txsNeedingSwapData = transactions.filter( + tx => tx.swapData == null && !tx.isSend + ) + if (txsNeedingSwapData.length > 0) { + mergeReportsTxInfo(account, wallet, txsNeedingSwapData).catch( + (err: unknown) => { + console.warn(err) + } + ) + } // Review triggers: deposit & transaction count for (const tx of transactions) { @@ -137,7 +151,7 @@ export function AccountCallbackManager(props: Props) { tx.savedAction?.actionType ?? tx.chainAction?.actionType if (!tx.isSend) { - dispatch(updateTransactionCount()).catch(err => { + dispatch(updateTransactionCount()).catch((err: unknown) => { console.warn(err) }) const exchangeDenom = getExchangeDenom( @@ -161,12 +175,12 @@ export function AccountCallbackManager(props: Props) { ) ) if (usdAmount > 0) { - dispatch(updateDepositAmount(usdAmount)).catch(err => { + dispatch(updateDepositAmount(usdAmount)).catch((err: unknown) => { console.warn(err) }) } } else if (actionType !== 'swap' && actionType !== 'fiat') { - dispatch(updateTransactionCount()).catch(err => { + dispatch(updateTransactionCount()).catch((err: unknown) => { console.warn(err) }) } @@ -181,11 +195,11 @@ export function AccountCallbackManager(props: Props) { if (account.username == null) { // Avoid showing modal for FIO wallets since the first transaction may be the handle creation if (wallet.currencyInfo.pluginId === 'fio') { - dispatch(refreshAllFioAddresses()).catch(err => { + dispatch(refreshAllFioAddresses()).catch((err: unknown) => { console.warn(err) }) } else { - showBackupModal({ navigation }).catch(error => { + showBackupModal({ navigation }).catch((error: unknown) => { showDevError(error) }) } @@ -244,7 +258,7 @@ export function AccountCallbackManager(props: Props) { if (dirty.walletList) { // Update all wallets (hammer mode): datelog('Updating wallet list') - await dispatch(refreshConnectedWallets).catch(err => { + await dispatch(refreshConnectedWallets).catch((err: unknown) => { console.warn(err) }) await snooze(1000) diff --git a/src/envConfig.ts b/src/envConfig.ts index cdb4fc9d5d5..71e27fbfc0f 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -515,6 +515,7 @@ export const asEnvConfig = asObject({ port: asOptional(asString, '8008') }) ), + REPORTS_SERVERS: asOptional(asArray(asString)), THEME_SERVER: asOptional( asObject({ host: asOptional(asString, 'localhost'), diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 51c353695a6..44fd29e66a1 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -735,6 +735,8 @@ const strings = { string_save: 'Save', string_share: 'Share', string_to_capitalize: 'To', + string_send_amount: 'Send Amount', + string_receive_amount: 'Receive Amount', string_show_balance: 'Show Balance', string_amount: 'Amount', string_value: 'Value', @@ -862,6 +864,7 @@ const strings = { transaction_details_empty_note_placeholder: 'Tap to Add Note (Optional)', transaction_details_exchange_details: 'Exchange Details', transaction_details_exchange_service: 'Exchange Service', + transaction_details_exchange_status: 'Exchange Status', transaction_details_exchange_order_id: 'Order ID', transaction_details_exchange_source_wallet: 'Source Wallet', transaction_details_exchange_destination_wallet: 'Destination Wallet', @@ -1126,6 +1129,7 @@ const strings = { 'This swap will create an order to exchange funds at the quoted rate but might only fulfill a portion of your order.\n\nFunds that fail to swap will remain in your source wallet or be returned.', fixed_quote: 'Fixed Quote', estimated_quote: 'Estimated Quote', + quote_type: 'Quote Type', estimated_exchange_message: 'The amount above is an estimate. This exchange may result in less funds received than quoted.', buy_sell_crypto_select_country_button: 'Select your region', @@ -1395,6 +1399,7 @@ const strings = { string_deny: 'Deny', string_wallet_balance: 'Wallet Balance', string_max_cap: 'MAX', + string_unknown: 'Unknown', string_warning: 'Warning', // Generic string. Same with wc_smartcontract_warning_title string_report_error: 'Report Error', string_report_sent: 'Report sent.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index cb33ea0f5ef..031cc44da49 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -571,6 +571,8 @@ "string_save": "Save", "string_share": "Share", "string_to_capitalize": "To", + "string_send_amount": "Send Amount", + "string_receive_amount": "Receive Amount", "string_show_balance": "Show Balance", "string_amount": "Amount", "string_value": "Value", @@ -688,6 +690,7 @@ "transaction_details_empty_note_placeholder": "Tap to Add Note (Optional)", "transaction_details_exchange_details": "Exchange Details", "transaction_details_exchange_service": "Exchange Service", + "transaction_details_exchange_status": "Exchange Status", "transaction_details_exchange_order_id": "Order ID", "transaction_details_exchange_source_wallet": "Source Wallet", "transaction_details_exchange_destination_wallet": "Destination Wallet", @@ -894,6 +897,7 @@ "can_be_partial_quote_body": "This swap will create an order to exchange funds at the quoted rate but might only fulfill a portion of your order.\n\nFunds that fail to swap will remain in your source wallet or be returned.", "fixed_quote": "Fixed Quote", "estimated_quote": "Estimated Quote", + "quote_type": "Quote Type", "estimated_exchange_message": "The amount above is an estimate. This exchange may result in less funds received than quoted.", "buy_sell_crypto_select_country_button": "Select your region", "search_region": "Search region", @@ -1100,6 +1104,7 @@ "string_deny": "Deny", "string_wallet_balance": "Wallet Balance", "string_max_cap": "MAX", + "string_unknown": "Unknown", "string_warning": "Warning", "string_report_error": "Report Error", "string_report_sent": "Report sent.", diff --git a/src/util/pickRandom.ts b/src/util/pickRandom.ts new file mode 100644 index 00000000000..7519311067a --- /dev/null +++ b/src/util/pickRandom.ts @@ -0,0 +1,9 @@ +/** + * Pick a random element from an array. + * + * @param arr - The array to pick from. + * @returns A random element from the array. + */ +export function pickRandom(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)] +} diff --git a/src/util/reportsServer.ts b/src/util/reportsServer.ts new file mode 100644 index 00000000000..e4a149c6a63 --- /dev/null +++ b/src/util/reportsServer.ts @@ -0,0 +1,258 @@ +import { mul } from 'biggystring' +import { + asArray, + asEither, + asJSON, + asNumber, + asObject, + asString +} from 'cleaners' +import type { + EdgeAccount, + EdgeCurrencyWallet, + EdgeTransaction, + EdgeTxActionSwap, + EdgeTxSwap +} from 'edge-core-js' + +import { ENV } from '../env' +import { getExchangeDenom } from '../selectors/DenominationSelectors' +import { asEdgeTokenId } from '../types/types' +import { cleanFetch } from './cleanFetch' +import { getCurrencyCode } from './CurrencyInfoHelpers' +import { pickRandom } from './pickRandom' + +// Constants +export const REPORTS_SERVERS = ENV.REPORTS_SERVERS ?? [ + 'https://reports1.edge.app' +] + +// Types +export type ReportsTxInfo = ReturnType + +// Cleaners +const asAssetInfo = asObject({ + address: asString, + pluginId: asString, + tokenId: asEdgeTokenId, + amount: asNumber +}) + +const asSwapInfo = asObject({ + orderId: asString, + pluginId: asString, + status: asString +}) + +export const asTxInfo = asObject({ + isoDate: asString, + swapInfo: asSwapInfo, + deposit: asAssetInfo, + payout: asAssetInfo +}) + +const asGetTxInfoSuccessResponse = asJSON( + asObject({ + txs: asArray(asTxInfo) + }) +) + +const asGetTxInfoFailureResponse = asJSON( + asObject({ + error: asObject({ + message: asString + }) + }) +) + +interface GetTxInfoRequest { + addressPrefix: string + startIsoDate: string + endIsoDate: string +} + +type GetTxInfoResponse = ReturnType +const asGetTxInfoResponse = asEither( + asGetTxInfoSuccessResponse, + asGetTxInfoFailureResponse +) + +const fetchGetTxInfo = cleanFetch({ + resource: input => input.endpoint, + asResponse: asGetTxInfoResponse +}) + +/** + * Fetches transaction information from the reports server for a given wallet + * and transaction. + * + * This function: + * - Extracts the first receive address from the transaction and truncates it + * to the first 5 characters (address prefix). + * - Sets a time window of 24 hours before and after the transaction date. + * - Queries the reports server for transactions matching the address prefix and + * time window. + * - Returns the first ReportsTxInfo where the destinationAddress matches the + * full address and the destinationAmount (in native units) matches the + * transaction's nativeAmount. + * + * @param wallet - The EdgeCurrencyWallet containing the transaction. + * @param transaction - The EdgeTransaction to look up. + * @returns The matching ReportsTxInfo if found, otherwise undefined. + * @throws If the reports server returns an error. + */ +export async function queryReportsTxInfo( + wallet: EdgeCurrencyWallet, + transaction: EdgeTransaction +): Promise { + const transactionDate = new Date(transaction.date * 1000) + const address = transaction.ourReceiveAddresses?.[0] + + if (address == null) { + return null + } + + // Get first 5 characters of the address + const addressHashfix = hashfix(address) + + // Set time range: 24 hours before and after transaction + const startDate = new Date(transactionDate) + startDate.setHours(startDate.getHours() - 24) + const endDate = new Date(transactionDate) + endDate.setHours(endDate.getHours() + 24) + + // Convert dates to ISO strings + const startIsoDate = startDate.toISOString() + const endIsoDate = endDate.toISOString() + + // Query the reports server: + const baseUrl = pickRandom(REPORTS_SERVERS) + const endpoint = new URL(`${baseUrl}/v1/getTxInfo`) + endpoint.searchParams.set('addressHashfix', addressHashfix.toString()) + endpoint.searchParams.set('startIsoDate', startIsoDate) + endpoint.searchParams.set('endIsoDate', endIsoDate) + const response = await fetchGetTxInfo({ + endpoint + }) + + if ('error' in response) { + throw new Error(response.error.message) + } + + // Find the first transaction where destinationAddress matches the full address + const denom = getExchangeDenom(wallet.currencyConfig, transaction.tokenId) + const matchingTx = response.txs.find(tx => { + const destinationNativeAmount = mul(tx.payout.amount, denom.multiplier) + return ( + tx.payout.address === address && + destinationNativeAmount === transaction.nativeAmount + ) + }) + + return matchingTx ?? null +} + +/** + * Converts a ReportsTxInfo to an EdgeTxSwap. + */ +export const toEdgeTxSwap = ( + account: EdgeAccount, + wallet: EdgeCurrencyWallet, + transaction: EdgeTransaction, + txInfo: ReportsTxInfo +): EdgeTxSwap | undefined => { + const swapPlugin = account.swapConfig[txInfo.swapInfo.pluginId] + if (swapPlugin == null) { + return + } + const payoutCurrencyCode = getCurrencyCode(wallet, transaction.tokenId) + const swapData: EdgeTxSwap = { + orderId: txInfo.swapInfo.orderId, + isEstimate: false, + + // The EdgeSwapInfo from the swap plugin: + plugin: { + pluginId: swapPlugin.swapInfo.pluginId, + displayName: swapPlugin.swapInfo.displayName, + supportEmail: swapPlugin.swapInfo.supportEmail + }, + + // Address information: + payoutAddress: txInfo.payout.address, + payoutCurrencyCode, + payoutNativeAmount: txInfo.payout.amount.toString(), + payoutWalletId: transaction.walletId + } + return swapData +} + +export const toEdgeTxActionSwap = ( + account: EdgeAccount, + transaction: EdgeTransaction, + txInfo: ReportsTxInfo +): EdgeTxActionSwap | undefined => { + const swapPlugin = account.swapConfig[txInfo.swapInfo.pluginId] + if (swapPlugin == null) { + return + } + return { + actionType: 'swap', + swapInfo: swapPlugin.swapInfo, + orderId: txInfo.swapInfo.orderId, + // orderUri, isEstimate, canBePartial, refundAddress are not available in + // txInfo, so we leave them undefined. + fromAsset: { + pluginId: txInfo.deposit.pluginId, + tokenId: txInfo.deposit.tokenId, + nativeAmount: txInfo.deposit.amount.toString() + }, + toAsset: { + pluginId: txInfo.payout.pluginId, + tokenId: txInfo.payout.tokenId, + nativeAmount: txInfo.payout.amount.toString() + }, + payoutAddress: txInfo.payout.address, + payoutWalletId: transaction.walletId + } +} + +/** + * This will merge reports-server txInfo data into receive transactions which + * are missing swap metadata. + */ +export async function mergeReportsTxInfo( + account: EdgeAccount, + wallet: EdgeCurrencyWallet, + transactions: EdgeTransaction[] +): Promise { + for (const transaction of transactions) { + const reportsTxInfo = await queryReportsTxInfo(wallet, transaction) + if (reportsTxInfo == null) continue + const swapData = toEdgeTxSwap(account, wallet, transaction, reportsTxInfo) + const swapAction = toEdgeTxActionSwap(account, transaction, reportsTxInfo) + if (swapData != null) { + transaction.swapData = swapData + transaction.savedAction = swapAction + wallet.saveTx(transaction).catch((err: unknown) => { + console.warn(err) + }) + } + } +} + +/** + * The hashfix is a 5 byte value that is used to identify the payout address + * semi-uniquely. + * + * It's named hashfix because it's not a prefix or suffix but a _hash_fix. + */ +function hashfix(address: string): number { + const space = 1099511627776 // 5 bytes of space; 2^40 + const prime = 769 // large prime number + let hashfix = 0 // the final hashfix + for (let i = 0; i < address.length; i++) { + const byte = address.charCodeAt(i) + hashfix = (hashfix * prime + byte) % space + } + return hashfix +}