diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e3724591ff..10450a9b7f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased (develop) - added: `ReturnKeyTypeButton` to `FlipInputModal2` +- added: Integrated reports server order status to transaction details. ## 4.32.0 (staging) diff --git a/package.json b/package.json index 3e392202247..30bd0fa19a6 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@react-navigation/native": "^6.1.3", "@react-navigation/stack": "^6.3.12", "@sentry/react-native": "^6.14.0", + "@tanstack/react-query": "^5.83.0", "@types/jsrsasign": "^10.5.13", "@unstoppabledomains/resolution": "^9.3.0", "@walletconnect/react-native-compat": "^2.11.0", diff --git a/src/components/App.tsx b/src/components/App.tsx index 536e2ef20b5..7eec3be7723 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,6 +1,7 @@ import '@ethersproject/shims' import { ErrorBoundary, Scope, wrap } from '@sentry/react-native' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import * as React from 'react' import { StyleSheet } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' @@ -12,6 +13,8 @@ import { EdgeCoreManager } from './services/EdgeCoreManager' import { StatusBarManager } from './services/StatusBarManager' import { ThemeProvider } from './services/ThemeContext' +const queryClient = new QueryClient() + function MainApp() { const handleBeforeCapture = useHandler((scope: Scope) => { scope.setLevel('fatal') @@ -19,19 +22,21 @@ function MainApp() { }) return ( - - - - } - > - - - - - - + + + + + } + > + + + + + + + ) } diff --git a/src/components/cards/SwapDetailsCard.tsx b/src/components/cards/SwapDetailsCard.tsx index 2a33888a1b8..39f7ed057a4 100644 --- a/src/components/cards/SwapDetailsCard.tsx +++ b/src/components/cards/SwapDetailsCard.tsx @@ -1,13 +1,12 @@ import { abs, sub } from 'biggystring' import { EdgeCurrencyWallet, EdgeTransaction, 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 { @@ -17,72 +16,81 @@ import { } from '../../selectors/DenominationSelectors' import { useSelector } from '../../types/reactRedux' import { getWalletName } from '../../util/CurrencyWalletHelpers' +import { ReportsTxInfo } from '../../util/reportsServer' import { convertNativeToDisplay, unixToLocaleDateTime } from '../../util/utils' -import { RawTextModal } from '../modals/RawTextModal' +import { DataSheetModal, DataSheetSection } from '../modals/DataSheetModal' +import { ShimmerText } from '../progress-indicators/ShimmerText' import { EdgeRow } from '../rows/EdgeRow' import { Airship, showError } from '../services/AirshipInstance' -import { cacheStyles, 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}}' export function SwapDetailsCard(props: Props) { - const { swapData, transaction, wallet } = props - const theme = useTheme() - const styles = getStyles(theme) - + const { + swapData, + transaction, + sourceWallet, + reportsTxInfo, + isReportsTxInfoLoading = false + } = props const { memos = [], spendTargets = [], tokenId } = transaction - const { currencyInfo } = wallet - const walletName = useWalletName(wallet) - const walletDefaultDenom = useSelector(state => - currencyInfo.currencyCode === transaction.currencyCode - ? getExchangeDenom(wallet.currencyConfig, tokenId) - : selectDisplayDenom(state, wallet.currencyConfig, tokenId) - ) - const { - isEstimate, - orderId, - orderUri, - payoutAddress, - payoutWalletId, - plugin, - refundAddress - } = swapData const formattedOrderUri = - orderUri == null + swapData.orderUri == null ? undefined - : orderUri.replace(TXID_PLACEHOLDER, transaction.txid) + : swapData.orderUri.replace(TXID_PLACEHOLDER, transaction.txid) const payoutCurrencyCode = swapData.payoutCurrencyCode 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 }, @@ -120,7 +128,7 @@ 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[payoutWalletId] + const destinationWallet = currencyWallets[swapData.payoutWalletId] const destinationWalletName = destinationWallet == null ? '' : getWalletName(destinationWallet) const destinationDenomination = useSelector(state => @@ -132,25 +140,37 @@ export function SwapDetailsCard(props: Props) { payoutCurrencyCode ) ) - 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 = payoutCurrencyCode === getExchangeDenom(destinationWallet.currencyConfig, null).name @@ -159,54 +179,120 @@ export function SwapDetailsCard(props: Props) { 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 ( @@ -216,25 +302,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..e0f962010ae --- /dev/null +++ b/src/components/modals/DataSheetModal.tsx @@ -0,0 +1,65 @@ +import * as React from 'react' +import { Fragment } from 'react' +import { ScrollView } from 'react-native' +import { 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 function DataSheetModal(props: Props) { + const { bridge, sections, title } = props + + const handleCancel = () => 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..ff3bda60057 --- /dev/null +++ b/src/components/progress-indicators/ShimmerText.tsx @@ -0,0 +1,137 @@ +import * as React from 'react' +import { View } from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import Animated, { + 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 = (props: Props) => { + const { isShown = true, characters, lines = 1 } = props + const theme = useTheme() + + const containerHeight = React.useMemo( + () => theme.rem(lines * 1.5), + [lines, theme] + ) + const containerWidth = React.useMemo( + () => + characters + ? characters * theme.rem(0.75) + : Math.floor(Math.random() * 80) + 20 + '%', + [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: string | number + height: string | number +}>(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: string | number + 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 2a44e66006b..bf1f3e58219 100644 --- a/src/components/rows/EdgeRow.tsx +++ b/src/components/rows/EdgeRow.tsx @@ -10,6 +10,7 @@ import { lstrings } from '../../locales/strings' import { triggerHaptic } from '../../util/haptic' import { fixSides, mapSides, sidesToMargin } from '../../util/sides' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { ShimmerText } from '../progress-indicators/ShimmerText' import { showToast } from '../services/AirshipInstance' import { cacheStyles, Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' @@ -34,6 +35,7 @@ interface Props { error?: boolean icon?: React.ReactNode loading?: boolean + shimmer?: boolean maximumHeight?: 'small' | 'medium' | 'large' rightButtonType?: RowActionIcon title?: string @@ -118,7 +120,9 @@ export const EdgeRow = (props: Props) => { {title} )} - {loading ? ( + {props.shimmer ? ( + + ) : loading ? ( { const styles = getStyles(theme) const iconColor = useIconColor({ pluginId: currencyInfo.pluginId, tokenId }) + const transactionSwapData = () => + 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 @@ const TransactionDetailsComponent = (props: Props) => { savedData } = getTxActionDisplayInfo(transaction, account, wallet) - const swapData = - convertActionToSwapData(account, transaction) ?? transaction.swapData + const swapData = transactionSwapData() ?? swapDataFromReports const thumbnailPath = useContactThumbnail(mergedData.name) ?? pluginIdIcons[iconPluginId ?? ''] @@ -546,7 +601,11 @@ const TransactionDetailsComponent = (props: Props) => { )} diff --git a/src/components/services/AccountCallbackManager.tsx b/src/components/services/AccountCallbackManager.tsx index 48a2fcfe24c..8d2b7d552dd 100644 --- a/src/components/services/AccountCallbackManager.tsx +++ b/src/components/services/AccountCallbackManager.tsx @@ -22,6 +22,7 @@ import { getExchangeDenom } from '../../selectors/DenominationSelectors' import { useDispatch, useSelector } from '../../types/reactRedux' import { NavigationBase } from '../../types/routerTypes' import { makePeriodicTask } from '../../util/PeriodicTask' +import { mergeReportsTxInfo } from '../../util/reportsServer' import { convertCurrencyFromExchangeRates, convertNativeToExchange, @@ -129,6 +130,15 @@ export function AccountCallbackManager(props: Props) { console.warn(err) ) + const txsNeedingSwapData = transactions.filter( + tx => tx.swapData == null && !tx.isSend + ) + if (txsNeedingSwapData.length > 0) { + mergeReportsTxInfo(account, wallet, txsNeedingSwapData).catch(err => + console.warn(err) + ) + } + // Review triggers: deposit & transaction count for (const tx of transactions) { const actionType = diff --git a/src/envConfig.ts b/src/envConfig.ts index cfcd137a453..7902de5eab6 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -442,6 +442,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 58a3a55d602..8a378423e46 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -731,6 +731,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_tap_next_for_quote: 'Tap "Next" for Quote', @@ -857,6 +859,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', @@ -1114,6 +1117,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_rate: 'Estimated Exchange Rate', estimated_exchange_rate_body: 'No exchange providers are able to provide a fixed quote for the exchange requested. This exchange may result in less funds received than quoted.', @@ -1380,6 +1384,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 step: 'Step', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 4b04df014fa..7a493bce1ef 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -565,6 +565,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_tap_next_for_quote": "Tap \"Next\" for Quote", @@ -681,6 +683,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", @@ -882,6 +885,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_rate": "Estimated Exchange Rate", "estimated_exchange_rate_body": "No exchange providers are able to provide a fixed quote for the exchange requested. This exchange may result in less funds received than quoted.", "estimated_exchange_message": "The amount above is an estimate. This exchange may result in less funds received than quoted.", @@ -1085,6 +1089,7 @@ "string_deny": "Deny", "string_wallet_balance": "Wallet Balance", "string_max_cap": "MAX", + "string_unknown": "Unknown", "string_warning": "Warning", "step": "Step", "scan_as_in_scan_barcode": "Scan", diff --git a/src/plugins/gui/providers/ioniaProvider.ts b/src/plugins/gui/providers/ioniaProvider.ts index 6bae6c706d8..71dc1f4484a 100644 --- a/src/plugins/gui/providers/ioniaProvider.ts +++ b/src/plugins/gui/providers/ioniaProvider.ts @@ -18,7 +18,7 @@ import { } from 'cleaners' import { EdgeParsedUri } from 'edge-core-js' import { sprintf } from 'sprintf-js' -import URL from 'url-parse' +import URLParse from 'url-parse' import { lstrings } from '../../../locales/strings' import { wasBase64 } from '../../../util/cleaners/asBase64' @@ -139,7 +139,7 @@ export const makeIoniaProvider: FiatProviderFactory = { // OAuth Access Token Request: const fetchAccessToken = cleanFetch({ - resource: `https://auth.craypay.com/connect/token`, + resource: new URL(`https://auth.craypay.com/connect/token`), options: { method: 'POST', headers: { @@ -162,7 +162,7 @@ export const makeIoniaProvider: FiatProviderFactory = { // Ionia Create User: const fetchCreateUserBase = cleanFetch({ - resource: `${pluginKeys.ioniaBaseUrl}/CreateUser`, + resource: new URL(`${pluginKeys.ioniaBaseUrl}/CreateUser`), options: ioniaBaseRequestOptions, asRequest: asJSON( asObject({ @@ -185,7 +185,7 @@ export const makeIoniaProvider: FiatProviderFactory = { // Ionia Get Gift Cards: const fetchGetGiftCardsBase = cleanFetch({ - resource: `${pluginKeys.ioniaBaseUrl}/GetGiftCards`, + resource: new URL(`${pluginKeys.ioniaBaseUrl}/GetGiftCards`), options: ioniaBaseRequestOptions, asRequest: asJSON( asOptional( @@ -203,7 +203,7 @@ export const makeIoniaProvider: FiatProviderFactory = { // Ionia Purchase Card Request: const fetchPurchaseGiftCardBase = cleanFetch({ - resource: `${pluginKeys.ioniaBaseUrl}/PurchaseGiftCard`, + resource: new URL(`${pluginKeys.ioniaBaseUrl}/PurchaseGiftCard`), options: ioniaBaseRequestOptions, asRequest: asJSON( asObject({ @@ -409,7 +409,7 @@ export const makeIoniaProvider: FiatProviderFactory = { cardAmount: number ): Promise { const cardPurchase = await queryPurchaseCard(currencyCode, cardAmount) - const paymentUrl = new URL(cardPurchase.uri, true) + const paymentUrl = new URLParse(cardPurchase.uri, true) const paymentRequestUrl = paymentUrl.query.r if (paymentRequestUrl == null) @@ -418,7 +418,7 @@ export const makeIoniaProvider: FiatProviderFactory = { ) const paymentProtocolResponse = await fetchPaymentOptions({ - endpoint: paymentRequestUrl + endpoint: new URL(paymentRequestUrl) }) const paymentOption = paymentProtocolResponse.paymentOptions.find( paymentOption => diff --git a/src/util/cleanFetch.ts b/src/util/cleanFetch.ts index 949aef6e03f..3ee0f1f2ea9 100644 --- a/src/util/cleanFetch.ts +++ b/src/util/cleanFetch.ts @@ -1,24 +1,85 @@ -import { Cleaner, uncleaner } from 'cleaners' +import { + asCodec, + asObject, + asString, + Cleaner, + CleanerShape, + uncleaner +} from 'cleaners' import deepmerge from 'deepmerge' -// Alias of fetch's first parameter -type URI = RequestInfo - export interface FetchSchema { + /** + * This is the request payload cleaner type. + * + * It is optional because the type maybe defined exclusively as a static type. + */ asRequest?: Cleaner + /** + * This is the response payload cleaner type. It will be used to clean the + * response data from the fetch response + */ asResponse: Cleaner + /** + * This is the fetch options to include with every request. It is identical + * to the options parameter for standard `fetch`. Options will be deep-merged + * with the options passed to the fetcher function. + */ options?: RequestInit - resource: URI | ((input: FetchInput) => URI | undefined) + /** + * Used to define the URL to fetch from. It can be a `URL` object or a function + * which derives the `URL` from the {@link FetchInput}. + * + * A function allows the URL to be derived from the request payload for + * use in the URL's query parameters. In addition, the resource function can + * extend the endpoint from {@link FetchInput}. + */ + resource: URL | ((input: FetchInput) => URL | undefined) } export type FetchInput = RequestInit & { - endpoint?: string + /** + * This is the endpoint to fetch from. + * + * It is optional because it can be defined by the {@link FetchSchema} via + * the `resource` (see {@link FetchSchema['resource']}). + */ + endpoint?: URL + /** + * This is the request payload body to send with the request. + * + * It is optional because it can be defined by the {@link FetchSchema} via + * the `asRequest` cleaner (see {@link FetchSchema['asRequest']}). + */ payload?: RequestPayload } +/** + * A fetcher function that can be used to fetch data from a given URL. + * It is designed to be an identical API to the standard `fetch` function. + * In addition to the standard `fetch` options, the fetcher function accepts + * an `endpoint` and `payload` property. + * + * The `endpoint` property is a standard `URL` object. This allows for the + * caller to control the URL of the request. + * + * The `payload` property is the request payload body in the expected type + * defined by the {@link FetchSchema}. + * + * @param input - The input to the fetcher. + * @returns A promise that resolves to the response payload. + */ export type Fetcher = ( input?: FetchInput ) => Promise +/** + * Creates a fetcher function that can be used to fetch data from a given URL. + * The fetcher function will automatically handle the request and response payloads, + * and will throw an error if the response is not valid. + * + * @param schema - The {@link FetchSchema} to use for the fetcher. + * @returns A {@link Fetcher} function that can be used to fetch data from a given URL. + */ export function cleanFetch( schema: FetchSchema ): Fetcher { @@ -28,21 +89,21 @@ export function cleanFetch( const fetcher = async ( input?: FetchInput ): Promise => { - const uri = + const url = typeof resource === 'function' ? resource(input ?? {}) : resource - if (uri == null) throw new Error(`Missing resource identifier (URI)`) + if (url == null) throw new Error(`Missing resource identifier (URL)`) const request = wasRequest(input?.payload) const options = deepmerge(schema.options ?? {}, { ...input, ...(request != null ? { body: request } : {}) }) - const fetchResponse = await fetch(uri, options) + const fetchResponse = await fetch(url, options) if (!fetchResponse.ok) { const message = await fetchResponse.text() throw new Error( - `${String(uri)} ${fetchResponse.status}${message ? `: ${message}` : ''}` + `${String(url)} ${fetchResponse.status}${message ? `: ${message}` : ''}` ) } @@ -76,3 +137,38 @@ export function fetcherWithOptions( ) } } + +/** + * Creates a cleaner that converts a URLSearchParams object to a given object + * shape. It is a codec cleaner so it can be uncleaned to get the original + * search params string. + * + * @param shape The object shape for the parameters. + * @returns A cleaner that converts a URLSearchParams object to a given object + * shape. + */ +export const asSearchParams = ( + shape: CleanerShape +): Cleaner => + asCodec( + (v: unknown): T => { + const search = new URLSearchParams(asString(v)) + const obj: Record = {} + for (const [key, value] of search) { + if (obj[key] == null) { + obj[key] = value + } else { + obj[key] = [obj[key]].flat().concat(value) + } + } + return asObject({ ...shape })(obj) + }, + params => { + const query = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + query.set(key, value) + }) + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return query.toString() + } + ) diff --git a/src/util/fake/FakeProviders.tsx b/src/util/fake/FakeProviders.tsx index 1ba4ec2c37b..850d48fc932 100644 --- a/src/util/fake/FakeProviders.tsx +++ b/src/util/fake/FakeProviders.tsx @@ -1,4 +1,5 @@ import { NavigationContext } from '@react-navigation/native' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import * as React from 'react' import { Metrics, SafeAreaProvider } from 'react-native-safe-area-context' import { Provider } from 'react-redux' @@ -9,6 +10,8 @@ import { rootReducer, RootState } from '../../reducers/RootReducer' import { renderStateProviders } from '../../state/renderStateProviders' import { fakeNavigation } from './fakeSceneProps' +const queryClient = new QueryClient() + type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial @@ -30,13 +33,15 @@ export function FakeProviders(props: Props) { [initialState] ) return ( - - {renderStateProviders( - - {children} - - )} - + + + {renderStateProviders( + + {children} + + )} + + ) } 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..8450b0e7043 --- /dev/null +++ b/src/util/reportsServer.ts @@ -0,0 +1,256 @@ +import { mul } from 'biggystring' +import { + asArray, + asEither, + asJSON, + asNumber, + asObject, + asString +} from 'cleaners' +import { + 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 => 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 +} diff --git a/yarn.lock b/yarn.lock index 5beed90e073..641a1fe4275 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5218,6 +5218,18 @@ dependencies: defer-to-connect "^2.0.1" +"@tanstack/query-core@5.83.0": + version "5.83.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.83.0.tgz#ac3bf337007bb7ea97b1fd2e7c3ceb4240f36dbc" + integrity sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA== + +"@tanstack/react-query@^5.83.0": + version "5.83.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.83.0.tgz#e72cacdb03d2e6a7e4f82f5b2fc228118941d499" + integrity sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ== + dependencies: + "@tanstack/query-core" "5.83.0" + "@testing-library/react-native@^13.2.0": version "13.2.0" resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-13.2.0.tgz#b4f53c69a889728abe8bc3115ba803824bcafe10" @@ -9636,9 +9648,9 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -"ecpair@git+https://github.com/EdgeApp/ecpair.git#b193eb8ea2ec0c93b528f4b0223a605407ff43e4": +"ecpair@https://github.com/EdgeApp/ecpair.git#b193eb8ea2ec0c93b528f4b0223a605407ff43e4": version "2.1.0" - resolved "git+https://github.com/EdgeApp/ecpair.git#b193eb8ea2ec0c93b528f4b0223a605407ff43e4" + resolved "https://github.com/EdgeApp/ecpair.git#b193eb8ea2ec0c93b528f4b0223a605407ff43e4" dependencies: "@types/bs58check" "^2.1.0" base-x "^4.0.0"