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"