From 7620e03ed71b6311a7087ed690e9198b2bd9509b Mon Sep 17 00:00:00 2001 From: Gustavo Cortez Date: Mon, 6 Apr 2026 11:21:42 -0300 Subject: [PATCH 001/138] Splash: Fix - startup navigation flicker on Android devices --- src/Root.tsx | 88 ++++++++++++++++--- src/navigation/onboarding/OnboardingGroup.tsx | 1 + 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/Root.tsx b/src/Root.tsx index 3412ac71ff..f7105d1e95 100644 --- a/src/Root.tsx +++ b/src/Root.tsx @@ -2,6 +2,7 @@ import { NavigationContainer, NavigationState, NavigatorScreenParams, + useNavigation, } from '@react-navigation/native'; import {createNativeStackNavigator} from '@react-navigation/native-stack'; import debounce from 'lodash.debounce'; @@ -16,6 +17,7 @@ import { Linking, NativeEventEmitter, NativeModules, + View, } from 'react-native'; import 'react-native-gesture-handler'; import {SafeAreaView} from 'react-native-safe-area-context'; @@ -168,6 +170,7 @@ const {Timer, SilentPushEvent, InAppMessageModule} = NativeModules; // ROOT NAVIGATION CONFIG export type RootStackParamList = { + StartupGate: undefined; Tabs: NavigatorScreenParams; AllAssets: {keyId?: string} | undefined; Allocation: @@ -281,6 +284,40 @@ export const getNavigationTabName = () => { export const Root = createNativeStackNavigator(); +const StartupGate = () => { + const navigation = useNavigation(); + const onboardingCompleted = useAppSelector( + ({APP}) => APP.onboardingCompleted, + ); + const appWasInit = useAppSelector(({APP}) => APP.appWasInit); + const appColorScheme = useAppSelector(({APP}) => APP.colorScheme); + const hasRoutedRef = useRef(false); + + const scheme = appColorScheme || Appearance.getColorScheme(); + const theme = scheme === 'dark' ? BitPayDarkTheme : BitPayLightTheme; + + useEffect(() => { + if (!appWasInit || hasRoutedRef.current) { + return; + } + + hasRoutedRef.current = true; + + navigation.reset({ + index: 0, + routes: [ + { + name: onboardingCompleted + ? RootStacks.TABS + : OnboardingScreens.ONBOARDING_START, + }, + ], + }); + }, [appWasInit, onboardingCompleted, navigation]); + + return ; +}; + export default () => { const dispatch = useAppDispatch(); const reduxStore = useStore(); @@ -289,6 +326,7 @@ export default () => { const {showOngoingProcess, hideOngoingProcess} = useOngoingProcess(); const lastSystemEnabledRef = useRef(null); const intervalRef = useRef | null>(null); + const splashHiddenRef = useRef(false); const onboardingCompleted = useAppSelector( ({APP}) => APP.onboardingCompleted, ); @@ -664,13 +702,10 @@ export default () => { const scheme = appColorScheme || Appearance.getColorScheme(); const theme = scheme === 'dark' ? BitPayDarkTheme : BitPayLightTheme; - // ROOT STACKS AND GLOBAL COMPONENTS - const initialRoute = onboardingCompleted - ? RootStacks.TABS - : OnboardingScreens.ONBOARDING_START; - return ( - + {showArchaxBanner && } {/* https://github.com/react-navigation/react-navigation/issues/11353#issuecomment-1548114655 */} @@ -682,9 +717,6 @@ export default () => { DeviceEventEmitter.emit(DeviceEmitterEvents.APP_NAVIGATION_READY); dispatch(showBlur(pinLockActive || biometricLockActive)); - await RNBootSplash.hide({fade: true}); - // avoid splash conflicting with modal in iOS - // https://stackoverflow.com/questions/65359539/showing-a-react-native-modal-right-after-app-startup-freezes-the-screen-in-ios logManager.debug( `Biometric Lock Active: ${biometricLockActive} | Pin Lock Active: ${pinLockActive}`, ); @@ -980,16 +1012,49 @@ export default () => { }, ); }} - onStateChange={debouncedOnStateChange}> + onStateChange={state => { + debouncedOnStateChange(state); + + if (splashHiddenRef.current) { + return; + } + + const currentRoute = navigationRef.getCurrentRoute()?.name; + + if (currentRoute && currentRoute !== 'StartupGate') { + splashHiddenRef.current = true; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + RNBootSplash.hide({fade: true}).catch(err => { + logManager.error( + `RNBootSplash.hide failed: ${ + err instanceof Error ? err.message : JSON.stringify(err) + }`, + ); + }); + }); + }); + } + }}> + initialRouteName="StartupGate"> + { component={TabsStack} options={{ gestureEnabled: false, + animation: 'none', }} /> { Date: Fri, 17 Apr 2026 18:37:46 -0300 Subject: [PATCH 002/138] XCode: Fix - Patch fmt consteval for Xcode 26/Clang compatibility --- ios/Podfile | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ios/Podfile b/ios/Podfile index e268607c9f..d820db64c8 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -79,6 +79,12 @@ target 'BitPayApp' do config.build_settings['OTHER_SWIFT_FLAGS'] << '-no-verify-emitted-module-interface' end end + if target.name == 'fmt' || target.name == 'RCT-Folly' + target.build_configurations.each do |config| + config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++20' + config.build_settings['OTHER_CPLUSPLUSFLAGS'] = '$(inherited) -DFMT_USE_NONTYPE_TEMPLATE_ARGS=0 -DFMT_CONSTEXPR=constexpr' + end + end end react_native_post_install( installer, @@ -95,6 +101,9 @@ target 'BitPayApp' do # This call modifies BitPayApp.xcodeproj — the change should be committed to git. # Subsequent `pod install` runs detect the existing phase and skip the modification. add_bundle_hash_build_phase(project) + + # Patch fmt FMT_CONSTEVAL to constexpr for Xcode 26 / Clang compatibility + system("sed -i '' 's/# define FMT_CONSTEVAL consteval/# define FMT_CONSTEVAL constexpr/' #{installer.sandbox.root}/fmt/include/fmt/base.h") end end From da44a44a87ff24b76ac40685b162496d10409d0c Mon Sep 17 00:00:00 2001 From: Gustavo Cortez Date: Mon, 20 Apr 2026 14:11:29 -0300 Subject: [PATCH 003/138] Location: Fix - return normalized location data and ensure dispatch completes --- src/store/app/app.effects.ts | 3 ++- src/store/location/location.effects.ts | 22 ++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/store/app/app.effects.ts b/src/store/app/app.effects.ts index 369cdcc95b..36477c180f 100644 --- a/src/store/app/app.effects.ts +++ b/src/store/app/app.effects.ts @@ -274,7 +274,7 @@ export const startAppInit = (): Effect => async (dispatch, getState) => { dispatch(initializeApi(network, identity)); - dispatch(LocationEffects.getLocationData()); + const locationDataPromise = dispatch(LocationEffects.getLocationData()); dispatch(fetchInitialUserData()); @@ -377,6 +377,7 @@ export const startAppInit = (): Effect => async (dispatch, getState) => { DeviceEventEmitter.emit(DeviceEmitterEvents.APP_INIT_COMPLETED); // Pre-fetch external services config and swap crypto currencies in background + await locationDataPromise; dispatch(prefetchExternalServicesData()); } catch (err: unknown) { let errorStr; diff --git a/src/store/location/location.effects.ts b/src/store/location/location.effects.ts index 3e19988a2f..418fbb6177 100644 --- a/src/store/location/location.effects.ts +++ b/src/store/location/location.effects.ts @@ -5,6 +5,7 @@ import {EUCountries} from './location.constants'; import cloneDeep from 'lodash.clonedeep'; import {logManager} from '../../managers/LogManager'; import {NO_CACHE_HEADERS} from '../../constants/config'; +import {LocationData} from './location.models'; export const isEuCountry = (countryShortCode: string | undefined): boolean => { if (!countryShortCode) { @@ -13,7 +14,8 @@ export const isEuCountry = (countryShortCode: string | undefined): boolean => { return EUCountries.includes(cloneDeep(countryShortCode).toUpperCase()); }; -export const getLocationData = (): Effect => async dispatch => { +export const getLocationData = + (): Effect> => async dispatch => { try { const {data: locationData} = await axios.get( 'https://bitpay.com/location/ipAddress', @@ -25,20 +27,24 @@ export const getLocationData = (): Effect => async dispatch => { }, ); + const normalizedLocationData: LocationData = { + countryShortCode: locationData.country, + isEuCountry: isEuCountry(locationData.country), + stateShortCode: locationData.state ?? undefined, + cityFullName: locationData.city ?? undefined, + locationFullName: locationData.locationString ?? undefined, + }; + logManager.info('getLocationData', locationData.country); await dispatch( LocationActions.successGetLocation({ - locationData: { - countryShortCode: locationData.country, - isEuCountry: isEuCountry(locationData.country), - stateShortCode: locationData.state ?? undefined, - cityFullName: locationData.city ?? undefined, - locationFullName: locationData.locationString ?? undefined, - }, + locationData: normalizedLocationData, }), ); + return normalizedLocationData; } catch (err) { const errStr = err instanceof Error ? err.message : JSON.stringify(err); logManager.error('getLocationData', errStr); + return undefined; } }; From b114c34efc6669a83e7e3ba5883bf58eb6330fb9 Mon Sep 17 00:00:00 2001 From: johnathan White Date: Mon, 20 Apr 2026 16:51:41 -0400 Subject: [PATCH 004/138] bump 14.43.0 --- android/app/build.gradle | 4 ++-- ios/BitPayApp/Info.plist | 2 +- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1566eb1563..a483cc4ee5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -104,8 +104,8 @@ android { applicationId "com.bitpay.wallet" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 91212415 - versionName "14.42.0" + versionCode 91212417 + versionName "14.43.0" missingDimensionStrategy 'react-native-camera', 'mlkit' ndk { abiFilters "armeabi-v7a", "x86", "x86_64", "arm64-v8a" diff --git a/ios/BitPayApp/Info.plist b/ios/BitPayApp/Info.plist index d2ad0323d6..5603b4378d 100644 --- a/ios/BitPayApp/Info.plist +++ b/ios/BitPayApp/Info.plist @@ -26,7 +26,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 14.42.0 + 14.43.0 CFBundleSignature ???? CFBundleURLTypes diff --git a/package.json b/package.json index 9354515be6..6a3d78da5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bitpay", - "version": "14.42.0", + "version": "14.43.0", "private": true, "engines": { "node": ">=20" From 1d8bc28761b7452b27f82b342a64cd1c6d278823 Mon Sep 17 00:00:00 2001 From: Gabriel Masclef Date: Wed, 22 Apr 2026 12:10:11 -0300 Subject: [PATCH 005/138] Fix: pre-select wallet from assets details correctly --- .../services/swap-crypto/screens/SwapCryptoRoot.tsx | 3 +++ src/navigation/wallet/screens/ExchangeRate.tsx | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/navigation/services/swap-crypto/screens/SwapCryptoRoot.tsx b/src/navigation/services/swap-crypto/screens/SwapCryptoRoot.tsx index 7d08f5b753..beb3825463 100644 --- a/src/navigation/services/swap-crypto/screens/SwapCryptoRoot.tsx +++ b/src/navigation/services/swap-crypto/screens/SwapCryptoRoot.tsx @@ -730,8 +730,11 @@ const SwapCryptoRoot: React.FC = () => { chain: `${cloneDeep(selectedWallet.chain).toUpperCase()}`, }, ); + logger.warn('It was not possible to set the selected wallet'); showError({msg}); selectedWallet = undefined; + await sleep(600); + setLoadingWalletFromStatus(false); return; } diff --git a/src/navigation/wallet/screens/ExchangeRate.tsx b/src/navigation/wallet/screens/ExchangeRate.tsx index bab2aae354..0da27d158c 100644 --- a/src/navigation/wallet/screens/ExchangeRate.tsx +++ b/src/navigation/wallet/screens/ExchangeRate.tsx @@ -128,6 +128,7 @@ import useExchangeRateChartData, { defaultDisplayData, HISTORIC_TIMEFRAME_WINDOW_MS, } from '../hooks/useExchangeRateChartData'; +import {SwapCryptoScreens} from '../../services/swap-crypto/SwapCryptoGroup'; const AxisLabel = ({ value, @@ -1750,9 +1751,8 @@ const ExchangeRate = () => { chain: assetContext.chain || '', }), ); - navigation.navigate('GlobalSelect', { - context: 'swapFrom', - assetContext, + navigation.navigate(SwapCryptoScreens.SWAP_CRYPTO_ROOT, { + selectedWallet: walletsForAsset[0]?.wallet || undefined, }); }, }} From e34b98a0897cdae9654886c50a37496c4fd440c3 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 22 Apr 2026 12:54:09 -0300 Subject: [PATCH 006/138] Swap: Fix - pre-select highest balance wallet from current account --- .../services/swap-crypto/screens/SwapCryptoRoot.tsx | 10 ++++++++++ src/navigation/wallet/screens/AccountDetails.tsx | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/navigation/services/swap-crypto/screens/SwapCryptoRoot.tsx b/src/navigation/services/swap-crypto/screens/SwapCryptoRoot.tsx index 7d08f5b753..b58f3e5827 100644 --- a/src/navigation/services/swap-crypto/screens/SwapCryptoRoot.tsx +++ b/src/navigation/services/swap-crypto/screens/SwapCryptoRoot.tsx @@ -244,6 +244,7 @@ import { export type SwapCryptoRootScreenParams = | { selectedWallet?: Wallet; + selectedAccount?: string; partner?: SwapCryptoExchangeKey; } | undefined; @@ -394,6 +395,7 @@ const SwapCryptoRoot: React.FC = () => { >(); const [offersLoading, setOffersLoading] = useState(false); let selectedWallet = route.params?.selectedWallet; + const selectedAccountAddress = route.params?.selectedAccount; const allSupportedTokens: string[] = [...tokenOptions, ...SUPPORTED_TOKENS]; const preSetPartner: SwapCryptoExchangeKey | undefined = route.params?.partner && @@ -754,6 +756,7 @@ const SwapCryptoRoot: React.FC = () => { } } else { // No pre-selected wallet: pick the wallet with the highest fiat balance + // pre-selected account: pick the wallet in the account with the highest fiat balance // from all keys, only considering wallets whose coin is in supportedCoins let bestWallet: Wallet | undefined; let bestFiatBalance = 0; @@ -767,6 +770,13 @@ const SwapCryptoRoot: React.FC = () => { return; } + if ( + selectedAccountAddress && + wallet.receiveAddress !== selectedAccountAddress + ) { + return; + } + const symbol = getExternalServiceSymbol( wallet.currencyAbbreviation, wallet.chain, diff --git a/src/navigation/wallet/screens/AccountDetails.tsx b/src/navigation/wallet/screens/AccountDetails.tsx index 52f29fe6ca..36a7367da5 100644 --- a/src/navigation/wallet/screens/AccountDetails.tsx +++ b/src/navigation/wallet/screens/AccountDetails.tsx @@ -1421,7 +1421,9 @@ const AccountDetails: React.FC = ({route}) => { context: 'AccountDetails', }), ); - navigation.navigate('SwapCryptoRoot'); + navigation.navigate('SwapCryptoRoot', { + selectedAccount: selectedAccountAddress, + }); }, }} receive={{ From c1dbb237d26e5d84649e7f2f229d346322c589e9 Mon Sep 17 00:00:00 2001 From: johnathan White Date: Thu, 23 Apr 2026 08:17:40 -0400 Subject: [PATCH 007/138] pipeline updates --- .github/workflows/e2e-android.yml | 113 + .github/workflows/e2e-ios.yml | 140 ++ .github/workflows/test.yml | 104 + .gitignore | 7 + .maestro/config.yaml | 2 + .maestro/flows/onboarding_to_home.yaml | 84 + babel.config.js | 25 +- index.js | 28 +- ios/BitPayApp.xcodeproj/project.pbxproj | 35 +- ios/BitPayApp/AppDelegate.swift | 10 +- ios/Podfile.lock | 2 +- jest.config.js | 19 +- package.json | 2 + src/components/anchor/Anchor.spec.tsx | 115 + src/components/button/Button.tsx | 3 +- src/components/button/ButtonOverlay.spec.tsx | 124 ++ src/components/checkbox/Checkbox.spec.tsx | 3 +- src/components/checkbox/Checkbox.tsx | 4 +- .../feature-card/FeatureCard.spec.tsx | 82 + src/components/loader/Loader.spec.tsx | 72 + src/components/tabs/Tabs.spec.tsx | 60 + .../onboarding/components/TermsBox.tsx | 11 +- .../onboarding/screens/Notifications.tsx | 1 + src/navigation/onboarding/screens/Pin.tsx | 10 + .../swap-crypto/components/BottomAmount.tsx | 6 +- .../wallet/components/FileOrText.tsx | 1 - src/store/app/app.reducer.spec.ts | 1035 +++++++++ src/store/backup/fs-backup.spec.ts | 293 +++ src/store/bitpay-id/bitpay-id.effects.spec.ts | 623 ++++++ .../buy-crypto/buy-crypto.effects.spec.ts | 291 +++ .../buy-crypto/buy-crypto.reducer.spec.ts | 799 +++++++ src/store/card/card.reducer.spec.ts | 698 +++++++ src/store/coinbase/coinbase.effects.spec.ts | 713 +++++++ src/store/contact/contact.effects.spec.ts | 416 ++++ src/store/location/location.effects.ts | 58 +- src/store/portfolio/portfolio.effects.spec.ts | 746 +++++++ src/store/scan/scan.spec.ts | 577 ++++- src/store/transforms/transforms.spec.ts | 822 ++++++++ .../wallet/effects/create/create.spec.ts | 725 +++++++ .../effects/create/getDecryptPassword.spec.js | 7 + .../effects/currencies/currencies.spec.ts | 357 ++++ .../wallet/effects/errors/errors.spec.ts | 185 ++ .../wallet/effects/import/import.spec.ts | 422 ++++ src/store/wallet/effects/rates/rates.spec.ts | 1687 +++++++++++++++ .../wallet/effects/status/status.spec.ts | 943 +++++++++ src/store/wallet/utils/wallet.spec.ts | 1764 ++++++++++++++++ src/store/wallet/wallet.reducer.spec.ts | 980 +++++++++ src/utils/color.spec.ts | 51 + src/utils/helper-methods.spec.ts | 1384 ++++++++++++ src/utils/passkey.spec.ts | 460 ++++ src/utils/password.spec.ts | 102 + src/utils/pin.spec.ts | 145 ++ src/utils/portfolio/assets.pure.spec.ts | 1848 +++++++++++++++++ .../portfolio/core/fiatRateSeries.spec.ts | 243 +++ src/utils/portfolio/core/format.spec.ts | 365 ++++ src/utils/portfolio/core/pnl/analysis.spec.ts | 1219 +++++++++++ src/utils/portfolio/core/pnl/rates.spec.ts | 635 ++++++ .../portfolio/core/pnl/snapshots.spec.ts | 1444 +++++++++++++ src/utils/portfolio/rate.spec.ts | 845 ++++++++ src/utils/text.spec.ts | 30 + test/mocks/SafeAreaView.js | 9 + test/setup.js | 208 +- yarn.lock | 31 +- 63 files changed, 24062 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/e2e-android.yml create mode 100644 .github/workflows/e2e-ios.yml create mode 100644 .github/workflows/test.yml create mode 100644 .maestro/config.yaml create mode 100644 .maestro/flows/onboarding_to_home.yaml create mode 100644 src/components/anchor/Anchor.spec.tsx create mode 100644 src/components/button/ButtonOverlay.spec.tsx create mode 100644 src/components/feature-card/FeatureCard.spec.tsx create mode 100644 src/components/loader/Loader.spec.tsx create mode 100644 src/components/tabs/Tabs.spec.tsx create mode 100644 src/store/app/app.reducer.spec.ts create mode 100644 src/store/backup/fs-backup.spec.ts create mode 100644 src/store/bitpay-id/bitpay-id.effects.spec.ts create mode 100644 src/store/buy-crypto/buy-crypto.effects.spec.ts create mode 100644 src/store/buy-crypto/buy-crypto.reducer.spec.ts create mode 100644 src/store/card/card.reducer.spec.ts create mode 100644 src/store/coinbase/coinbase.effects.spec.ts create mode 100644 src/store/contact/contact.effects.spec.ts create mode 100644 src/store/portfolio/portfolio.effects.spec.ts create mode 100644 src/store/transforms/transforms.spec.ts create mode 100644 src/store/wallet/effects/create/create.spec.ts create mode 100644 src/store/wallet/effects/currencies/currencies.spec.ts create mode 100644 src/store/wallet/effects/errors/errors.spec.ts create mode 100644 src/store/wallet/effects/import/import.spec.ts create mode 100644 src/store/wallet/effects/rates/rates.spec.ts create mode 100644 src/store/wallet/effects/status/status.spec.ts create mode 100644 src/store/wallet/utils/wallet.spec.ts create mode 100644 src/store/wallet/wallet.reducer.spec.ts create mode 100644 src/utils/color.spec.ts create mode 100644 src/utils/helper-methods.spec.ts create mode 100644 src/utils/passkey.spec.ts create mode 100644 src/utils/password.spec.ts create mode 100644 src/utils/pin.spec.ts create mode 100644 src/utils/portfolio/assets.pure.spec.ts create mode 100644 src/utils/portfolio/core/fiatRateSeries.spec.ts create mode 100644 src/utils/portfolio/core/format.spec.ts create mode 100644 src/utils/portfolio/core/pnl/analysis.spec.ts create mode 100644 src/utils/portfolio/core/pnl/rates.spec.ts create mode 100644 src/utils/portfolio/core/pnl/snapshots.spec.ts create mode 100644 src/utils/portfolio/rate.spec.ts create mode 100644 src/utils/text.spec.ts create mode 100644 test/mocks/SafeAreaView.js diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml new file mode 100644 index 0000000000..29a538f3a2 --- /dev/null +++ b/.github/workflows/e2e-android.yml @@ -0,0 +1,113 @@ +name: E2E Android + +on: + workflow_dispatch: + +concurrency: + group: e2e-android-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-android: + name: E2E Android Smoke + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version-file: '.node-version' + cache: 'yarn' + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Create env file + env: + ENV_FILE: ${{ secrets.ENV_DEVELOPMENT }} + run: | + echo "$ENV_FILE" > .env.development + echo "IS_MAESTRO=true" >> .env.development + + - name: Create Sentry properties + run: | + echo "${{ secrets.SENTRY_PROPERTIES }}" > sentry.properties + echo "${{ secrets.SENTRY_PROPERTIES }}" > ios/sentry.properties + echo "${{ secrets.SENTRY_PROPERTIES }}" > android/sentry.properties + + - name: Set native config values + run: yarn set:dev + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle- + + - name: Cache Maestro + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-${{ runner.os }} + restore-keys: maestro-${{ runner.os }} + + - name: Install Maestro + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + - name: Build Android APK (debug) + run: | + cd android + ./gradlew assembleDebug --no-daemon + + - name: Enable KVM for emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Start Android emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 30 + arch: x86_64 + profile: pixel_6 + avd-name: e2e-emulator + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + disable-animations: true + script: | + adb install android/app/build/outputs/apk/debug/app-debug.apk + maestro test .maestro/flows/ --format junit --output maestro-results.xml + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: maestro-android-results + path: | + maestro-results.xml + ~/.maestro/tests/ + retention-days: 7 + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v5 + with: + name: maestro-android-screenshots + path: ~/.maestro/tests/**/*.png + retention-days: 7 + diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml new file mode 100644 index 0000000000..1d900541d9 --- /dev/null +++ b/.github/workflows/e2e-ios.yml @@ -0,0 +1,140 @@ +name: E2E iOS + +on: + workflow_dispatch: + +concurrency: + group: e2e-ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-ios: + name: E2E iOS Smoke + runs-on: macos-latest-xlarge + timeout-minutes: 75 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: '20' + + - name: Cache node_modules + id: cache-node-modules + uses: actions/cache@v4 + with: + path: node_modules + key: node-modules-${{ hashFiles('yarn.lock') }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + + - name: Generate DKLS vendor files + run: node ./scripts/generate-dkls-vendor.js + + - name: Create env file + env: + ENV_FILE: ${{ secrets.ENV_DEVELOPMENT }} + run: | + echo "$ENV_FILE" > .env.development + echo "IS_MAESTRO=true" >> .env.development + + - name: Create Sentry properties + run: | + echo "${{ secrets.SENTRY_PROPERTIES }}" > sentry.properties + echo "${{ secrets.SENTRY_PROPERTIES }}" > ios/sentry.properties + echo "${{ secrets.SENTRY_PROPERTIES }}" > android/sentry.properties + + - name: Set native config values + run: yarn set:dev + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7.6' + bundler-cache: true + + - name: Select Xcode + run: sudo xcode-select -switch /Applications/Xcode_16.4.app + + - name: Clean Xcode caches + run: | + rm -rf ios/build + rm -rf ~/Library/Developer/Xcode/DerivedData || true + + - name: Install CocoaPods + run: cd ios && bundle exec pod install + + - name: Build iOS app for simulator + env: + CI: "1" + SENTRY_DISABLE_AUTO_UPLOAD: true + NODE_OPTIONS: "--max_old_space_size=8192" + run: | + export NODE_BINARY=$(which node) + export PATH="$(dirname "$NODE_BINARY"):$PATH" + xcodebuild \ + -workspace ios/BitPayApp.xcworkspace \ + -scheme BitPayApp \ + -configuration Debug \ + -sdk iphonesimulator \ + -derivedDataPath ios/build \ + -destination 'generic/platform=iOS Simulator' \ + NODE_BINARY="$NODE_BINARY" \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG MAESTRO" \ + FORCE_BUNDLING=YES \ + ONLY_ACTIVE_ARCH=YES \ + ARCHS=arm64 \ + build + + - name: Cache Maestro + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-${{ runner.os }} + restore-keys: maestro-${{ runner.os }} + + - name: Install Maestro + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + - name: Boot simulator + run: | + UDID=$(xcrun simctl list devices available | grep -E "iPhone [0-9]" | head -1 | grep -E -o '\(([0-9A-F-]+)\)' | tr -d '()') + echo "Booting simulator: $UDID" + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + echo "SIMULATOR_UDID=$UDID" >> $GITHUB_ENV + + - name: Install app on simulator + run: xcrun simctl install "$SIMULATOR_UDID" ios/build/Build/Products/Debug-iphonesimulator/BitPayApp.app + + - name: Run Maestro smoke tests + run: maestro test .maestro/flows/ --format junit --output maestro-results.xml + env: + MAESTRO_DRIVER_STARTUP_TIMEOUT: 600000 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: maestro-ios-results + path: | + maestro-results.xml + ~/.maestro/tests/ + retention-days: 7 + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v5 + with: + name: maestro-ios-screenshots + path: | + ~/.maestro/tests/**/*.png + ./*.png + retention-days: 7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..90fc2cf62a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,104 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + formatting: + name: Formatting + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version-file: '.node-version' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Check formatting + run: yarn prettier:check + + types: + name: Types + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version-file: '.node-version' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Validate types + run: yarn validate + + lint: + name: Lint + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version-file: '.node-version' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Lint (changed lines only) + uses: reviewdog/action-eslint@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-pr-review + eslint_flags: '--ext .js,.jsx,.ts,.tsx src/' + fail_on_error: true + filter_mode: diff_context + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version-file: '.node-version' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run tests + run: yarn test:ci + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v5 + with: + name: coverage-report + path: coverage/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 54a2c9062d..8cabbfd949 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ +# Claude Code +.claude + +# Maestro +android/samples/ +.maestro/screenshots/ + # OSX # .DS_Store diff --git a/.maestro/config.yaml b/.maestro/config.yaml new file mode 100644 index 0000000000..fca3ef1c2f --- /dev/null +++ b/.maestro/config.yaml @@ -0,0 +1,2 @@ +flows: + - flows/onboarding_to_home.yaml diff --git a/.maestro/flows/onboarding_to_home.yaml b/.maestro/flows/onboarding_to_home.yaml new file mode 100644 index 0000000000..35b08a99bf --- /dev/null +++ b/.maestro/flows/onboarding_to_home.yaml @@ -0,0 +1,84 @@ +appId: com.bitpay.wallet +--- +- launchApp: + clearState: true + +# Onboarding start - wait longer for cold start on CI +- extendedWaitUntil: + visible: + id: "onboarding-start-view" + timeout: 600000 +- takeScreenshot: "01-onboarding-start" +- waitForAnimationToEnd +- tapOn: + id: "continue-without-an-account-button" + +# Notifications screen +- extendedWaitUntil: + visible: + id: "set-notifications-view" + timeout: 15000 +- takeScreenshot: "02-notifications" +- waitForAnimationToEnd +- tapOn: + id: "deny-button" +- takeScreenshot: "03-after-notifications-deny" + +# Protect your wallet (security) screen +- extendedWaitUntil: + visible: + id: "security-view" + timeout: 30000 +- takeScreenshot: "04-security-view" +- waitForAnimationToEnd +- tapOn: + id: "skip-security-button" +- takeScreenshot: "05-after-security-skip" + +# Create key screen +- extendedWaitUntil: + visible: + id: "create-key-view" + timeout: 15000 +- takeScreenshot: "06-create-key" +- waitForAnimationToEnd +- tapOn: + id: "i-already-have-a-key-button" + +# Import screen - enter seed phrase +- extendedWaitUntil: + visible: + id: "import-view" + timeout: 15000 +- takeScreenshot: "07-import" +- tapOn: + id: "import-text-input" +- inputText: "gown pizza sell law yard laundry gown action enemy speed embark awkward" + +# Import wallet (keyboardShouldPersistTaps='handled' allows tap through keyboard) +- tapOn: + id: "import-wallet-button" + +# Wait for import to complete and terms screen to appear (may take time on slow networks) +- extendedWaitUntil: + visible: + id: "terms-of-use-container" + timeout: 120000 +- takeScreenshot: "08-terms" + +# Important - tap all 3 checkboxes and agree +- assertVisible: + id: "terms-of-use-container" +- tapOn: + id: "first-term-checkbox" +- tapOn: + id: "second-term-checkbox" +- tapOn: + id: "third-term-checkbox" +- tapOn: + id: "agree-and-continue-button" + +# Verify home screen is visible +- assertVisible: + id: "portfolio-balance-toggle" +- takeScreenshot: "09-home" diff --git a/babel.config.js b/babel.config.js index 7214153c63..fef7222dd6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,7 @@ const {NODE_ENV} = process.env; const prod = NODE_ENV === 'production'; +const isTest = NODE_ENV === 'test'; const plugins = [ 'babel-plugin-transform-import-meta', @@ -10,15 +11,21 @@ const plugins = [ '@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-nullish-coalescing-operator', '@babel/plugin-transform-template-literals', - [ - 'module:react-native-dotenv', - { - moduleName: '@env', - path: prod ? '.env.production' : '.env.development', - safe: true, - allowUndefined: true, - }, - ], + // In test env, @env is handled by Jest's moduleNameMapper so the dotenv + // babel plugin is skipped (it requires .env files that are gitignored). + ...(!isTest + ? [ + [ + 'module:react-native-dotenv', + { + moduleName: '@env', + path: prod ? '.env.production' : '.env.development', + safe: true, + allowUndefined: true, + }, + ], + ] + : []), [ 'module-resolver', { diff --git a/index.js b/index.js index 8f03b818bb..9a28fd992d 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,15 @@ import 'react-native-get-random-values'; // must import before @ethersproject/shims -import { install as installQuickCrypto } from 'react-native-quick-crypto'; +import {install as installQuickCrypto} from 'react-native-quick-crypto'; import '@ethersproject/shims'; // import 'fast-text-encoding'; import './shim'; import '@walletconnect/react-native-compat'; -import {AppRegistry, Alert, StatusBar, Appearance} from 'react-native'; +import {AppRegistry, Alert, StatusBar, Appearance, LogBox} from 'react-native'; +import {IS_MAESTRO} from '@env'; + +if (IS_MAESTRO === 'true') { + LogBox.ignoreAllLogs(); +} import Root from './src/Root'; import React, {useState, useEffect} from 'react'; import './i18n'; @@ -74,10 +79,9 @@ Sentry.init({ return breadcrumb; } return null; - } + }, }); - installQuickCrypto(); const makeErrorHandler = store => (e, isFatal) => { @@ -175,8 +179,8 @@ const ReduxProvider = () => { return ( - - {storeRehydrated => (storeRehydrated ? : null)} + + {storeRehydrated => (storeRehydrated ? : null)} ); @@ -199,11 +203,13 @@ const AppWrapper = () => { updateTheme(); - const subscription = Appearance.addChangeListener(({colorScheme: newScheme}) => { - if (colorScheme === null) { - setIsDark(newScheme === 'dark'); - } - }); + const subscription = Appearance.addChangeListener( + ({colorScheme: newScheme}) => { + if (colorScheme === null) { + setIsDark(newScheme === 'dark'); + } + }, + ); return () => subscription.remove(); }, [colorScheme]); diff --git a/ios/BitPayApp.xcodeproj/project.pbxproj b/ios/BitPayApp.xcodeproj/project.pbxproj index c1c35679ed..8665f7982f 100644 --- a/ios/BitPayApp.xcodeproj/project.pbxproj +++ b/ios/BitPayApp.xcodeproj/project.pbxproj @@ -190,7 +190,6 @@ 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, EBB510F948BF432C893398ED /* Upload Debug Symbols to Sentry */, AF19107D42FF74BA8F0049CF /* Inject Bundle Hash */, - C3CF03A4A13B8DA5B5BB02BA /* [CP] Embed Pods Frameworks */, A4F9FF5781B0ADEE84C32EAD /* [CP] Copy Pods Resources */, ); @@ -268,21 +267,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nSENTRY_XCODE=\"../node_modules/@sentry/react-native/scripts/sentry-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $SENTRY_XCODE\"\n"; - }; - EBB510F948BF432C893398ED /* Upload Debug Symbols to Sentry */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Upload Debug Symbols to Sentry"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "export SENTRY_PROPERTIES=\"$SRCROOT/../sentry.properties\"\n/bin/sh \"$SRCROOT/../node_modules/@sentry/react-native/scripts/sentry-xcode-debug-files.sh\"\n"; + shellScript = "set -e\n\nif [ -z \"$NODE_BINARY\" ]; then\n export NODE_BINARY=$(command -v node || true)\nfi\nif [ -z \"$NODE_BINARY\" ] || [ ! -x \"$NODE_BINARY\" ]; then\n echo \"error: NODE_BINARY not set and 'node' not found on PATH.\"\n exit 1\nfi\necho \"Using NODE_BINARY=$NODE_BINARY\"\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nSENTRY_XCODE=\"../node_modules/@sentry/react-native/scripts/sentry-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $SENTRY_XCODE\"\n"; }; 3C1070649A3322DC1391852F /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -359,6 +344,20 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-BitPayApp/Pods-BitPayApp-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + EBB510F948BF432C893398ED /* Upload Debug Symbols to Sentry */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Upload Debug Symbols to Sentry"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export SENTRY_PROPERTIES=\"$SRCROOT/../sentry.properties\"\n/bin/sh \"$SRCROOT/../node_modules/@sentry/react-native/scripts/sentry-xcode-debug-files.sh\"\n"; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -412,6 +411,7 @@ "-ObjC", "-lc++", ); + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = com.bitpay.wallet; PRODUCT_NAME = BitPayApp; PROVISIONING_PROFILE_SPECIFIER = "BitPay Development Profile (ApplePay)"; @@ -420,6 +420,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OBJC_BRIDGING_HEADER = "BitPayApp-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; @@ -452,6 +453,7 @@ "-ObjC", "-lc++", ); + OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = com.bitpay.wallet; PRODUCT_NAME = BitPayApp; PROVISIONING_PROFILE_SPECIFIER = "BitPay Development Profile (ApplePay)"; @@ -459,6 +461,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OBJC_BRIDGING_HEADER = "BitPayApp-Bridging-Header.h"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; diff --git a/ios/BitPayApp/AppDelegate.swift b/ios/BitPayApp/AppDelegate.swift index a000d3d992..52eceb77f9 100644 --- a/ios/BitPayApp/AppDelegate.swift +++ b/ios/BitPayApp/AppDelegate.swift @@ -104,7 +104,10 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { } override func bundleURL() -> URL? { -#if DEBUG +#if MAESTRO + // On CI there is no Metro server — use the embedded bundle (requires FORCE_BUNDLING=YES in xcodebuild) + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#elseif DEBUG return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") #else return Bundle.main.url(forResource: "main", withExtension: "jsbundle") @@ -143,10 +146,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BrazeInAppMessageUIDelega // Verify React Native bundle integrity to prevent tampering. // On failure: show a blocking security window and return early without starting React Native. // We return true (not false) so UIKit doesn't log a spurious launch-URL error. + // Skipped during Maestro E2E test runs (MAESTRO compilation condition set by CI build). + #if !MAESTRO if !verifyBundleIntegrity() { showBundleTamperedAlert() return true } + #endif // 1. React Native setup using factory let rnDelegate = ReactNativeDelegate() @@ -247,6 +253,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BrazeInAppMessageUIDelega NSLog("BitPay: Failed to set file protection: \(error)") } + #if !MAESTRO // Custom NSURLProtocol to whitelist URL prefixes RCTSetCustomNSURLSessionConfigurationProvider { let configuration = URLSessionConfiguration.default @@ -258,6 +265,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BrazeInAppMessageUIDelega configuration.protocolClasses = classes return configuration } + #endif // Set UNUserNotificationCenter delegate to receive foreground notifications UNUserNotificationCenter.current().delegate = self diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4f9065d6ce..64691ee585 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4065,6 +4065,6 @@ SPEC CHECKSUMS: VisionCamera: 05e4bc4783174689a5878a0797015ab32afae9e4 Yoga: 93bc00d78638987f9ffd928f4a9f895d3e601bc3 -PODFILE CHECKSUM: 952017b0e6612cb4f6a51284e91d08856e0c2a13 +PODFILE CHECKSUM: 9d0418736883a30a1763e2b7e0e5244ea1a618eb COCOAPODS: 1.15.2 diff --git a/jest.config.js b/jest.config.js index ad4560eaef..e60b7d78fd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,7 +7,7 @@ module.exports = { ], transformIgnorePatterns: [ '\\.snap$', - 'node_modules/(?!(@walletconnect/react-native-compat|@freakycoder|@react-native|react-native|(react-native(-.*))|@react-navigation|(react-navigation(-.*))|victory|(victory(-.*))))', + 'node_modules/(?!(@walletconnect/react-native-compat|@freakycoder|@react-native|react-native|(react-native(-.*))|\@react-navigation|(react-navigation(-.*))|\@sentry|uuid|victory|(victory(-.*))|lodash-es))', ], transform: { '^.+\\.(js|jsx|ts|tsx)$': 'ts-jest', @@ -22,6 +22,23 @@ module.exports = { '\\.(css|less)$': '/test/mock.js', '@/(.*)': '/src/$1', '@test/(.*)': '/test/$1', + // Redirect bare styled-components to the native version (some files import + // 'styled-components' instead of 'styled-components/native', which breaks in tests) + '^styled-components$': '/node_modules/styled-components/native/dist/styled-components.native.cjs.js', + // Force ESM-only crypto packages to CJS builds + '^uuid$': require.resolve('uuid'), + '^paillier-bigint$': '/node_modules/paillier-bigint/dist/cjs/index.node.cjs', + '^bigint-crypto-utils$': '/node_modules/bigint-crypto-utils/dist/cjs/index.node.cjs', + '^@env$': '/test/mock.js', }, roots: ['/src/'], + collectCoverageFrom: [ + 'src/utils/**/*.{ts,tsx}', + 'src/store/**/*.{ts,tsx}', + 'src/components/**/*.{ts,tsx}', + '!src/**/*.spec.{ts,tsx,js}', + '!src/**/__tests__/**', + '!src/**/*.d.ts', + ], + coverageReporters: ['text-summary', 'lcov', 'json-summary'], }; diff --git a/package.json b/package.json index 6a3d78da5e..c6d8ea0f30 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "ios:device": "export NODE_OPTIONS=--openssl-legacy-provider && react-native run-ios --device", "ios:device:release": "export NODE_OPTIONS=--openssl-legacy-provider && react-native run-ios --configuration Release --device", "start": "export NODE_OPTIONS=--openssl-legacy-provider && react-native start", + "test:ci": "jest --ci --testMatch='**/*.spec.{js,tsx,ts}' --config='jest.config.js'", "test:coverage": "jest --coverage --testMatch='**/*.spec.{js,tsx,ts}' --config='jest.config.js'", "test:unit": "jest --watch --testMatch='**/*.spec.{js,tsx,ts}' --config='jest.config.js'", "test:cache": "jest --cache", @@ -24,6 +25,7 @@ "lint": "eslint --fix . --ext .js,.jsx,.ts,.tsx", "validate": "tsc --noEmit --skipLibCheck 2>&1 | grep -v node_modules | grep -E 'TS(2304|2693|2349|2351|2448|2554|2307|2454|2305|2774)' && exit 1 || exit 0", "prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", + "prettier:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", "postinstall": "./scripts/postinstall.sh", "clean": "react-native-clean-project", "pod:install": "command -v pod && (cd ios/ && bundle exec pod install && cd ..) || echo \"pod command not found\"", diff --git a/src/components/anchor/Anchor.spec.tsx b/src/components/anchor/Anchor.spec.tsx new file mode 100644 index 0000000000..f2a4cf2396 --- /dev/null +++ b/src/components/anchor/Anchor.spec.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import {Linking} from 'react-native'; +import {Provider} from 'react-redux'; +import {render, fireEvent} from '@test/render'; +import Anchor from './Anchor'; +import configureTestStore from '@test/store'; + +// Mock the app effect so we can verify dispatch calls without real side effects +jest.mock('../../store/app/app.effects', () => ({ + openUrlWithInAppBrowser: jest.fn((href: string) => ({ + type: 'APP/OPEN_URL_WITH_IN_APP_BROWSER', + payload: href, + })), +})); + +import {openUrlWithInAppBrowser} from '../../store/app/app.effects'; + +const renderWithStore = (ui: React.ReactElement, initialState = {}) => { + const store = configureTestStore(initialState); + return render({ui}); +}; + +describe('Anchor', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders children text', () => { + const {getByText} = renderWithStore( + Visit BitPay, + ); + expect(getByText('Visit BitPay')).toBeTruthy(); + }); + + it('renders with no href without crashing', () => { + const {getByText} = renderWithStore(No link); + expect(getByText('No link')).toBeTruthy(); + }); + + it('does not dispatch when pressed with no href', () => { + const {getByText} = renderWithStore(No link); + fireEvent.press(getByText('No link')); + expect(openUrlWithInAppBrowser).not.toHaveBeenCalled(); + }); + + it('opens in-app browser when href is provided and not download', async () => { + (Linking.canOpenURL as jest.Mock) = jest.fn(() => Promise.resolve(true)); + + const {getByText} = renderWithStore( + Visit BitPay, + ); + fireEvent.press(getByText('Visit BitPay')); + + // Allow the async onPress to resolve + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(openUrlWithInAppBrowser).toHaveBeenCalledWith('https://bitpay.com'); + }); + + it('calls Linking.openURL when download=true and URL can be opened', async () => { + (Linking.canOpenURL as jest.Mock) = jest.fn(() => Promise.resolve(true)); + const openURLSpy = jest + .spyOn(Linking, 'openURL') + .mockResolvedValue(undefined); + + const {getByText} = renderWithStore( + + Download PDF + , + ); + fireEvent.press(getByText('Download PDF')); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(openURLSpy).toHaveBeenCalledWith('https://bitpay.com/file.pdf'); + expect(openUrlWithInAppBrowser).not.toHaveBeenCalled(); + openURLSpy.mockRestore(); + }); + + it('falls back to in-app browser when download=true but URL cannot be opened', async () => { + (Linking.canOpenURL as jest.Mock) = jest.fn(() => Promise.resolve(false)); + const openURLSpy = jest.spyOn(Linking, 'openURL'); + + const {getByText} = renderWithStore( + + Download PDF + , + ); + fireEvent.press(getByText('Download PDF')); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(openURLSpy).not.toHaveBeenCalled(); + expect(openUrlWithInAppBrowser).toHaveBeenCalledWith( + 'https://bitpay.com/file.pdf', + ); + openURLSpy.mockRestore(); + }); + + it('dispatches in-app browser when Linking.canOpenURL throws', async () => { + (Linking.canOpenURL as jest.Mock) = jest.fn(() => + Promise.reject(new Error('permission denied')), + ); + + const {getByText} = renderWithStore( + Visit BitPay, + ); + fireEvent.press(getByText('Visit BitPay')); + + await new Promise(resolve => setTimeout(resolve, 0)); + + // canOpenURL resolves to false via .catch(() => false), so in-app browser is used + expect(openUrlWithInAppBrowser).toHaveBeenCalledWith('https://bitpay.com'); + }); +}); diff --git a/src/components/button/Button.tsx b/src/components/button/Button.tsx index e490343a9a..d5d8a445bc 100644 --- a/src/components/button/Button.tsx +++ b/src/components/button/Button.tsx @@ -321,6 +321,7 @@ const Button: React.FC> = props => { accessibilityLabel, touchableLibrary, icon, + testID, } = props; const secondary = buttonStyle === 'secondary'; const outline = buttonOutline; @@ -399,7 +400,7 @@ const Button: React.FC> = props => { buttonType={buttonType} onPress={debouncedOnPress} activeOpacity={disabled ? 1 : ActiveOpacity} - testID={'button'}> + testID={testID || 'button'}> { + it('renders without crashing when not visible', () => { + const {toJSON} = render( + , + ); + expect(toJSON()).toBeTruthy(); + }); + + it('renders without crashing when visible', () => { + const {toJSON} = render( + , + ); + expect(toJSON()).toBeTruthy(); + }); + + it('renders children when provided', () => { + const {getByText} = render( + + Overlay Child + , + ); + expect(getByText('Overlay Child')).toBeTruthy(); + }); + + it('renders with pill buttonType', () => { + const {toJSON} = render( + , + ); + expect(toJSON()).toBeTruthy(); + }); + + it('renders with link buttonType', () => { + const {toJSON} = render( + , + ); + expect(toJSON()).toBeTruthy(); + }); + + it('renders with secondary buttonStyle', () => { + const {toJSON} = render( + , + ); + expect(toJSON()).toBeTruthy(); + }); + + it('renders with a custom backgroundColor', () => { + const {toJSON} = render( + , + ); + expect(toJSON()).toBeTruthy(); + }); + + it('renders with animate=true', () => { + const {toJSON} = render( + , + ); + expect(toJSON()).toBeTruthy(); + }); + + it('renders with animate=false', () => { + const {toJSON} = render( + , + ); + expect(toJSON()).toBeTruthy(); + }); + + it('transitions from visible to not visible without crashing', () => { + const {rerender, toJSON} = render( + , + ); + rerender( + , + ); + expect(toJSON()).toBeTruthy(); + }); +}); diff --git a/src/components/checkbox/Checkbox.spec.tsx b/src/components/checkbox/Checkbox.spec.tsx index bb3f913a0b..ea49557a5e 100644 --- a/src/components/checkbox/Checkbox.spec.tsx +++ b/src/components/checkbox/Checkbox.spec.tsx @@ -9,7 +9,8 @@ it('renders correctly', async () => { , ); const checkbox = await getByTestId('checkbox'); - expect(getByTestId('checkboxBorder')).toHaveStyle({borderColor: SlateDark}); + // On light theme (used by test render), unchecked border is #E5E5F2 + expect(getByTestId('checkboxBorder')).toHaveStyle({borderColor: '#E5E5F2'}); fireEvent(checkbox, 'press'); expect(mockFn).toHaveBeenCalled(); diff --git a/src/components/checkbox/Checkbox.tsx b/src/components/checkbox/Checkbox.tsx index 81ffc1d058..7989a8c27b 100644 --- a/src/components/checkbox/Checkbox.tsx +++ b/src/components/checkbox/Checkbox.tsx @@ -12,6 +12,7 @@ interface Props { radio?: boolean; radioHeight?: number; checkHeight?: number; + testID?: string; } interface BorderProps { @@ -55,6 +56,7 @@ const Checkbox: React.FC = ({ radio, radioHeight, checkHeight, + testID, }) => { const radioStyles = radioHeight ? {height: radioHeight, width: radioHeight} @@ -75,7 +77,7 @@ const Checkbox: React.FC = ({ ...baseStyles, }} // @ts-ignore --> testing - testID="checkbox" + testID={testID || 'checkbox'} accessibilityLabel="Checkbox" outerStyle={{ ...baseStyles, diff --git a/src/components/feature-card/FeatureCard.spec.tsx b/src/components/feature-card/FeatureCard.spec.tsx new file mode 100644 index 0000000000..abe5e5fe64 --- /dev/null +++ b/src/components/feature-card/FeatureCard.spec.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import {fireEvent, render} from '@test/render'; +import FeatureCard from './FeatureCard'; + +jest.mock('react-native-linear-gradient', () => { + const React = require('react'); + const {View} = require('react-native'); + return ({children, ...rest}: any) => {children}; +}); + +const defaultProps = { + image: {uri: 'https://example.com/image.png'}, + descriptionTitle: 'Test Feature Title', + descriptionText: 'This is a description of the feature.', + ctaText: 'Learn More', + cta: jest.fn(), +}; + +describe('FeatureCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the description title', () => { + const {getByText} = render(); + expect(getByText('Test Feature Title')).toBeTruthy(); + }); + + it('renders the description text', () => { + const {getByText} = render(); + expect(getByText('This is a description of the feature.')).toBeTruthy(); + }); + + it('renders the CTA button element', () => { + // The Button renders with a testID even when the gesture-handler mock + // does not expose inner text children in the test environment + const {getByTestId} = render(); + expect(getByTestId('button')).toBeTruthy(); + }); + + it('calls cta when the CTA button is pressed', () => { + const cta = jest.fn(); + const {getByTestId} = render(); + fireEvent(getByTestId('button'), 'press'); + expect(cta).toHaveBeenCalledTimes(1); + }); + + it('does not call cta when a different cta function is used', () => { + const cta = jest.fn(); + const otherCta = jest.fn(); + const {getByTestId} = render(); + fireEvent(getByTestId('button'), 'press'); + expect(cta).toHaveBeenCalledTimes(1); + expect(otherCta).not.toHaveBeenCalled(); + }); + + it('renders different title and description text props', () => { + const {getByText} = render( + , + ); + expect(getByText('Secure Your Wallet')).toBeTruthy(); + expect( + getByText('Keep your crypto safe with our advanced security.'), + ).toBeTruthy(); + }); + + it('calls the correct cta after re-render with a new cta prop', () => { + const firstCta = jest.fn(); + const secondCta = jest.fn(); + const {getByTestId, rerender} = render( + , + ); + rerender(); + fireEvent(getByTestId('button'), 'press'); + expect(secondCta).toHaveBeenCalledTimes(1); + expect(firstCta).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/loader/Loader.spec.tsx b/src/components/loader/Loader.spec.tsx new file mode 100644 index 0000000000..e09231bae8 --- /dev/null +++ b/src/components/loader/Loader.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import {View} from 'react-native'; +import {render} from '@test/render'; +import Loader from './Loader'; + +// LoaderSvg relies on @shopify/react-native-skia APIs (MakeFromSVGString, Matrix, +// SweepGradient, vec) that are not fully covered by the global Skia mock. +// Stub it out so Loader's own animation logic is the focus of these tests. +jest.mock('./LoaderSvg', () => { + const React = require('react'); + const {View} = require('react-native'); + return ({size}: {size?: number}) => ( + + ); +}); + +describe('Loader', () => { + it('renders without crashing with default props', () => { + const {toJSON} = render(); + expect(toJSON()).toBeTruthy(); + }); + + it('renders the inner LoaderSvg', () => { + const {getByTestId} = render(); + expect(getByTestId('loader-svg')).toBeTruthy(); + }); + + it('passes the default size (32) to LoaderSvg', () => { + const {getByTestId} = render(); + const svg = getByTestId('loader-svg'); + expect(svg.props.style).toMatchObject({width: 32, height: 32}); + }); + + it('passes a custom size to LoaderSvg', () => { + const {getByTestId} = render(); + const svg = getByTestId('loader-svg'); + expect(svg.props.style).toMatchObject({width: 64, height: 64}); + }); + + it('renders when spinning is true (default)', () => { + const {getByTestId} = render(); + expect(getByTestId('loader-svg')).toBeTruthy(); + }); + + it('renders when spinning is false (animation stopped)', () => { + const {getByTestId} = render(); + expect(getByTestId('loader-svg')).toBeTruthy(); + }); + + it('applies a custom style to the wrapping Animated.View', () => { + const customStyle = {margin: 10}; + const {toJSON} = render(); + const tree = toJSON() as any; + // The outermost element should include the custom style + const styles = Array.isArray(tree?.props?.style) + ? tree.props.style + : [tree?.props?.style]; + const hasCustomMargin = styles.some((s: any) => s && s.margin === 10); + expect(hasCustomMargin).toBe(true); + }); + + it('transitions from spinning to not spinning without crashing', () => { + const {rerender, getByTestId} = render(); + rerender(); + expect(getByTestId('loader-svg')).toBeTruthy(); + }); + + it('renders with a custom durationMs', () => { + const {getByTestId} = render(); + expect(getByTestId('loader-svg')).toBeTruthy(); + }); +}); diff --git a/src/components/tabs/Tabs.spec.tsx b/src/components/tabs/Tabs.spec.tsx new file mode 100644 index 0000000000..af0fd8d829 --- /dev/null +++ b/src/components/tabs/Tabs.spec.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import {Text} from 'react-native'; +import {Provider} from 'react-redux'; +import {render, fireEvent} from '@test/render'; +import {ThemeProvider} from 'styled-components/native'; +import {BitPayLightTheme} from '../../themes/bitpay'; +import Tabs from './Tabs'; +import configureTestStore from '@test/store'; + +const renderWithStore = (ui: React.ReactElement, initialState = {}) => { + const store = configureTestStore(initialState); + return render({ui}); +}; + +const makeTabs = () => [ + {title: Tab One, content: Content One}, + {title: Tab Two, content: Content Two}, + {title: Tab Three, content: Content Three}, +]; + +describe('Tabs', () => { + it('renders all tab titles', () => { + const {getByText} = renderWithStore(); + expect(getByText('Tab One')).toBeTruthy(); + expect(getByText('Tab Two')).toBeTruthy(); + expect(getByText('Tab Three')).toBeTruthy(); + }); + + it('renders the first tab content by default', () => { + const {getByText} = renderWithStore(); + expect(getByText('Content One')).toBeTruthy(); + }); + + it('switches to second tab content when second tab is pressed', () => { + const {getByText, queryByText} = renderWithStore(); + fireEvent.press(getByText('Tab Two')); + expect(getByText('Content Two')).toBeTruthy(); + // First tab content should no longer be shown + expect(queryByText('Content One')).toBeNull(); + }); + + it('switches between tabs correctly', () => { + const {getByText} = renderWithStore(); + // Switch to third tab + fireEvent.press(getByText('Tab Three')); + expect(getByText('Content Three')).toBeTruthy(); + // Switch back to first tab + fireEvent.press(getByText('Tab One')); + expect(getByText('Content One')).toBeTruthy(); + }); + + it('renders with a single tab', () => { + const singleTab = () => [ + {title: Only Tab, content: Only Content}, + ]; + const {getByText} = renderWithStore(); + expect(getByText('Only Tab')).toBeTruthy(); + expect(getByText('Only Content')).toBeTruthy(); + }); +}); diff --git a/src/navigation/onboarding/components/TermsBox.tsx b/src/navigation/onboarding/components/TermsBox.tsx index d527f47ace..49d0803a87 100644 --- a/src/navigation/onboarding/components/TermsBox.tsx +++ b/src/navigation/onboarding/components/TermsBox.tsx @@ -45,9 +45,16 @@ const TermsBox = ({term, emit}: Props) => { }; return ( - + - + {statement} diff --git a/src/navigation/onboarding/screens/Notifications.tsx b/src/navigation/onboarding/screens/Notifications.tsx index b4067293f6..66ef9711e0 100644 --- a/src/navigation/onboarding/screens/Notifications.tsx +++ b/src/navigation/onboarding/screens/Notifications.tsx @@ -81,6 +81,7 @@ const NotificationsScreen = ({ testID="skip-button" accessibilityLabel="Skip" buttonType={'pill'} + touchableLibrary={'react-native'} onPress={onSkipPressRef.current}> {t('Skip')} diff --git a/src/navigation/onboarding/screens/Pin.tsx b/src/navigation/onboarding/screens/Pin.tsx index 63ce79b96c..197683ade4 100644 --- a/src/navigation/onboarding/screens/Pin.tsx +++ b/src/navigation/onboarding/screens/Pin.tsx @@ -81,6 +81,7 @@ const PinScreen = ({ testID="skip-button" accessibilityLabel="Skip" buttonType={'pill'} + touchableLibrary={'react-native'} onPress={onSkipPressRef.current}> {t('Skip')} @@ -165,6 +166,15 @@ const PinScreen = ({ {t('Biometric')} + + + diff --git a/src/navigation/services/swap-crypto/components/BottomAmount.tsx b/src/navigation/services/swap-crypto/components/BottomAmount.tsx index 7f22a34f42..49caaec42a 100644 --- a/src/navigation/services/swap-crypto/components/BottomAmount.tsx +++ b/src/navigation/services/swap-crypto/components/BottomAmount.tsx @@ -488,9 +488,9 @@ const BottomAmount: React.FC = ({ Number(limitsOpts.limits.minAmount) * 1.05; const minAmount = _minAmount.toString(); if (primaryIsFiat && rate) { - const minAmountFiat = ( - _minAmount * rate - ).toFixed(2); + const minAmountFiat = (_minAmount * rate).toFixed( + 2, + ); curValRef.current = minAmountFiat; updateAmountRef.current(minAmountFiat, true); } else { diff --git a/src/navigation/wallet/components/FileOrText.tsx b/src/navigation/wallet/components/FileOrText.tsx index b0fc6a1523..1c5401a31f 100644 --- a/src/navigation/wallet/components/FileOrText.tsx +++ b/src/navigation/wallet/components/FileOrText.tsx @@ -302,7 +302,6 @@ const FileOrText = () => { source: 'FileOrText', }), ); - } catch (err: any) { const errMsg = err instanceof Error ? err.message : JSON.stringify(err); logger.error(errMsg); diff --git a/src/store/app/app.reducer.spec.ts b/src/store/app/app.reducer.spec.ts new file mode 100644 index 0000000000..2f827dc412 --- /dev/null +++ b/src/store/app/app.reducer.spec.ts @@ -0,0 +1,1035 @@ +/** + * Tests for app.reducer.ts + * + * Each action handled by appReducer is exercised as a pure function: + * appReducer(state, action) → newState + * + * No Redux store or middleware is needed — reducers are pure functions. + */ + +import {appReducer, AppState} from './app.reducer'; +import {AppActionTypes} from './app.types'; +import {Network} from '../../constants'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Return a fresh state by calling reducer with undefined state and unknown action */ +const freshState = (): AppState => + appReducer(undefined, {type: '@@INIT'} as any); + +// --------------------------------------------------------------------------- +// Default state +// --------------------------------------------------------------------------- + +describe('appReducer — default state', () => { + it('returns a state with expected defaults when called with undefined and unknown action', () => { + const state = freshState(); + expect(state.appIsLoading).toBe(true); + expect(state.appWasInit).toBe(false); + expect(state.appIsReadyForDeeplinking).toBe(false); + expect(state.onboardingCompleted).toBe(false); + expect(state.showWalletConnectStartModal).toBe(false); + expect(state.showInAppNotification).toBe(false); + expect(state.showBottomNotificationModal).toBe(false); + expect(state.showChainSelectorModal).toBe(false); + expect(state.notificationsAccepted).toBe(false); + expect(state.pinLockActive).toBe(false); + expect(state.biometricLockActive).toBe(false); + expect(state.showPortfolioValue).toBe(true); + expect(state.hideAllBalances).toBe(false); + expect(state.brazeContentCards).toEqual([]); + expect(state.migrationComplete).toBe(false); + expect(state.EDDSAKeyMigrationCompleteV2).toBe(false); + expect(state.keyMigrationFailure).toBe(false); + expect(state.activeModalId).toBeNull(); + expect(state.failedAppInit).toBe(false); + expect(state.hasViewedZenLedgerWarning).toBe(false); + expect(state.hasViewedBillsTab).toBe(false); + expect(state.dismissedMarketingCardIds).toEqual([]); + expect(state.isImportLedgerModalVisible).toBe(false); + expect(state.inAppBrowserOpen).toBe(false); + expect(state.tokensDataLoaded).toBe(false); + expect(state.showArchaxBanner).toBe(false); + }); + + it('returns same state on unknown action', () => { + const state = freshState(); + const next = appReducer(state, {type: 'COMPLETELY_UNKNOWN'} as any); + expect(next).toBe(state); + }); +}); + +// --------------------------------------------------------------------------- +// IMPORT_LEDGER_MODAL_TOGGLED +// --------------------------------------------------------------------------- + +describe('IMPORT_LEDGER_MODAL_TOGGLED', () => { + it('sets isImportLedgerModalVisible to true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.IMPORT_LEDGER_MODAL_TOGGLED, + payload: true, + }); + expect(state.isImportLedgerModalVisible).toBe(true); + }); + + it('sets isImportLedgerModalVisible to false', () => { + const base: AppState = {...freshState(), isImportLedgerModalVisible: true}; + const state = appReducer(base, { + type: AppActionTypes.IMPORT_LEDGER_MODAL_TOGGLED, + payload: false, + }); + expect(state.isImportLedgerModalVisible).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// NETWORK_CHANGED +// --------------------------------------------------------------------------- + +describe('NETWORK_CHANGED', () => { + it('updates the network field', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.NETWORK_CHANGED, + payload: Network.testnet, + }); + expect(state.network).toBe(Network.testnet); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_APP_INIT / APP_INIT_COMPLETE +// --------------------------------------------------------------------------- + +describe('SUCCESS_APP_INIT', () => { + it('sets appIsLoading to false', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SUCCESS_APP_INIT, + }); + expect(state.appIsLoading).toBe(false); + }); +}); + +describe('APP_INIT_COMPLETE', () => { + it('sets appWasInit to true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.APP_INIT_COMPLETE, + }); + expect(state.appWasInit).toBe(true); + }); +}); + +describe('APP_TOKENS_DATA_LOADED', () => { + it('sets tokensDataLoaded to true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.APP_TOKENS_DATA_LOADED, + }); + expect(state.tokensDataLoaded).toBe(true); + }); +}); + +describe('APP_READY_FOR_DEEPLINKING', () => { + it('sets appIsReadyForDeeplinking to true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.APP_READY_FOR_DEEPLINKING, + }); + expect(state.appIsReadyForDeeplinking).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// SET_APP_FIRST_OPEN_EVENT_COMPLETE / SET_APP_FIRST_OPEN_DATE +// --------------------------------------------------------------------------- + +describe('SET_APP_FIRST_OPEN_EVENT_COMPLETE', () => { + it('sets firstOpenEventComplete to true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_APP_FIRST_OPEN_EVENT_COMPLETE, + }); + expect(state.appFirstOpenData.firstOpenEventComplete).toBe(true); + }); + + it('preserves existing firstOpenDate', () => { + const base: AppState = { + ...freshState(), + appFirstOpenData: {firstOpenEventComplete: false, firstOpenDate: 12345}, + }; + const state = appReducer(base, { + type: AppActionTypes.SET_APP_FIRST_OPEN_EVENT_COMPLETE, + }); + expect(state.appFirstOpenData.firstOpenDate).toBe(12345); + }); +}); + +describe('SET_APP_FIRST_OPEN_DATE', () => { + it('sets firstOpenDate to the given timestamp', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_APP_FIRST_OPEN_DATE, + payload: 99999, + }); + expect(state.appFirstOpenData.firstOpenDate).toBe(99999); + }); +}); + +// --------------------------------------------------------------------------- +// SET_ONBOARDING_COMPLETED / SET_APP_INSTALLED +// --------------------------------------------------------------------------- + +describe('SET_ONBOARDING_COMPLETED', () => { + it('sets onboardingCompleted to true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_ONBOARDING_COMPLETED, + }); + expect(state.onboardingCompleted).toBe(true); + }); +}); + +describe('SET_APP_INSTALLED', () => { + it('sets appInstalled to true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_APP_INSTALLED, + }); + expect(state.appInstalled).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// SHOW/DISMISS WALLET CONNECT START MODAL +// --------------------------------------------------------------------------- + +describe('SHOW_WALLET_CONNECT_START_MODAL / DISMISS_WALLET_CONNECT_START_MODAL', () => { + it('shows the modal', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_WALLET_CONNECT_START_MODAL, + }); + expect(state.showWalletConnectStartModal).toBe(true); + }); + + it('dismisses the modal', () => { + const base: AppState = {...freshState(), showWalletConnectStartModal: true}; + const state = appReducer(base, { + type: AppActionTypes.DISMISS_WALLET_CONNECT_START_MODAL, + }); + expect(state.showWalletConnectStartModal).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// SHOW/DISMISS IN_APP_NOTIFICATION +// --------------------------------------------------------------------------- + +describe('SHOW_IN_APP_NOTIFICATION / DISMISS_IN_APP_NOTIFICATION', () => { + it('shows the in-app notification with data', () => { + const payload = { + message: 'Test notification', + context: 'walletConnect' as any, + request: undefined as any, + }; + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_IN_APP_NOTIFICATION, + payload, + }); + expect(state.showInAppNotification).toBe(true); + expect(state.inAppNotificationData).toEqual(payload); + }); + + it('dismisses the notification and clears data', () => { + const base: AppState = { + ...freshState(), + showInAppNotification: true, + inAppNotificationData: { + message: 'msg', + context: 'walletConnect' as any, + request: undefined as any, + }, + }; + const state = appReducer(base, { + type: AppActionTypes.DISMISS_IN_APP_NOTIFICATION, + }); + expect(state.showInAppNotification).toBe(false); + expect(state.inAppNotificationData).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// SHOW/DISMISS/RESET BOTTOM NOTIFICATION MODAL +// --------------------------------------------------------------------------- + +describe('SHOW_BOTTOM_NOTIFICATION_MODAL', () => { + it('shows the modal with config', () => { + const config = {title: 'Test', message: 'Body', type: 'info'} as any; + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_BOTTOM_NOTIFICATION_MODAL, + payload: config, + }); + expect(state.showBottomNotificationModal).toBe(true); + expect(state.bottomNotificationModalConfig).toEqual(config); + }); +}); + +describe('DISMISS_BOTTOM_NOTIFICATION_MODAL', () => { + it('dismisses the modal', () => { + const base: AppState = {...freshState(), showBottomNotificationModal: true}; + const state = appReducer(base, { + type: AppActionTypes.DISMISS_BOTTOM_NOTIFICATION_MODAL, + }); + expect(state.showBottomNotificationModal).toBe(false); + }); +}); + +describe('RESET_BOTTOM_NOTIFICATION_MODAL_CONFIG', () => { + it('clears the modal config', () => { + const base: AppState = { + ...freshState(), + bottomNotificationModalConfig: {title: 'Test'} as any, + }; + const state = appReducer(base, { + type: AppActionTypes.RESET_BOTTOM_NOTIFICATION_MODAL_CONFIG, + }); + expect(state.bottomNotificationModalConfig).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// SHOW/DISMISS CHAIN SELECTOR MODAL +// --------------------------------------------------------------------------- + +describe('SHOW_CHAIN_SELECTOR_MODAL / DISMISS_CHAIN_SELECTOR_MODAL / CLEAR_CHAIN_SELECTOR_MODAL_OPTIONS', () => { + it('shows chain selector modal with config', () => { + const config = {onDismiss: jest.fn()} as any; + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_CHAIN_SELECTOR_MODAL, + payload: config, + }); + expect(state.showChainSelectorModal).toBe(true); + expect(state.chainSelectorModalConfig).toEqual(config); + }); + + it('dismisses chain selector modal', () => { + const base: AppState = {...freshState(), showChainSelectorModal: true}; + const state = appReducer(base, { + type: AppActionTypes.DISMISS_CHAIN_SELECTOR_MODAL, + }); + expect(state.showChainSelectorModal).toBe(false); + }); + + it('clears chain selector modal options', () => { + const base: AppState = { + ...freshState(), + chainSelectorModalConfig: {onDismiss: jest.fn()} as any, + }; + const state = appReducer(base, { + type: AppActionTypes.CLEAR_CHAIN_SELECTOR_MODAL_OPTIONS, + }); + expect(state.chainSelectorModalConfig).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// SET_COLOR_SCHEME +// --------------------------------------------------------------------------- + +describe('SET_COLOR_SCHEME', () => { + it('sets colorScheme to dark', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_COLOR_SCHEME, + payload: 'dark', + }); + expect(state.colorScheme).toBe('dark'); + }); + + it('sets colorScheme to light', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_COLOR_SCHEME, + payload: 'light', + }); + expect(state.colorScheme).toBe('light'); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_GENERATE_APP_IDENTITY +// --------------------------------------------------------------------------- + +describe('SUCCESS_GENERATE_APP_IDENTITY', () => { + it('sets identity for the given network', () => { + const identity = {priv: 'priv-key', pub: 'pub-key', sin: 'sin-val'}; + const state = appReducer(freshState(), { + type: AppActionTypes.SUCCESS_GENERATE_APP_IDENTITY, + payload: {network: Network.mainnet, identity}, + }); + expect(state.identity[Network.mainnet]).toEqual(identity); + }); + + it('does not overwrite identity for other networks', () => { + const base = freshState(); + const state = appReducer(base, { + type: AppActionTypes.SUCCESS_GENERATE_APP_IDENTITY, + payload: { + network: Network.testnet, + identity: {priv: 'p', pub: 'q', sin: 'r'}, + }, + }); + expect(state.identity[Network.mainnet]).toEqual( + base.identity[Network.mainnet], + ); + }); +}); + +// --------------------------------------------------------------------------- +// SET_NOTIFICATIONS_ACCEPTED / SET_CONFIRMED_TX_ACCEPTED / SET_ANNOUNCEMENTS_ACCEPTED +// --------------------------------------------------------------------------- + +describe('notification preference actions', () => { + it('SET_NOTIFICATIONS_ACCEPTED sets notificationsAccepted', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_NOTIFICATIONS_ACCEPTED, + payload: true, + }); + expect(state.notificationsAccepted).toBe(true); + }); + + it('SET_CONFIRMED_TX_ACCEPTED sets confirmedTxAccepted', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_CONFIRMED_TX_ACCEPTED, + payload: true, + }); + expect(state.confirmedTxAccepted).toBe(true); + }); + + it('SET_ANNOUNCEMENTS_ACCEPTED sets announcementsAccepted', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_ANNOUNCEMENTS_ACCEPTED, + payload: true, + }); + expect(state.announcementsAccepted).toBe(true); + }); + + it('SET_EMAIL_NOTIFICATIONS_ACCEPTED sets emailNotifications', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_EMAIL_NOTIFICATIONS_ACCEPTED, + payload: {accepted: true, email: 'test@example.com'}, + }); + expect(state.emailNotifications.accepted).toBe(true); + expect(state.emailNotifications.email).toBe('test@example.com'); + }); +}); + +// --------------------------------------------------------------------------- +// SHOW/DISMISS ONBOARDING FINISH MODAL +// --------------------------------------------------------------------------- + +describe('SHOW_ONBOARDING_FINISH_MODAL / DISMISS_ONBOARDING_FINISH_MODAL', () => { + it('shows the onboarding finish modal', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_ONBOARDING_FINISH_MODAL, + }); + expect(state.showOnboardingFinishModal).toBe(true); + }); + + it('dismisses the onboarding finish modal', () => { + const base: AppState = {...freshState(), showOnboardingFinishModal: true}; + const state = appReducer(base, { + type: AppActionTypes.DISMISS_ONBOARDING_FINISH_MODAL, + }); + expect(state.showOnboardingFinishModal).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// SET_DEFAULT_LANGUAGE +// --------------------------------------------------------------------------- + +describe('SET_DEFAULT_LANGUAGE', () => { + it('sets defaultLanguage', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_DEFAULT_LANGUAGE, + payload: 'fr', + }); + expect(state.defaultLanguage).toBe('fr'); + }); +}); + +// --------------------------------------------------------------------------- +// SHOW/DISMISS/RESET DECRYPT PASSWORD MODAL +// --------------------------------------------------------------------------- + +describe('SHOW_DECRYPT_PASSWORD_MODAL / DISMISS / RESET', () => { + it('shows the modal with config', () => { + const config = {onSubmitHandler: jest.fn()} as any; + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_DECRYPT_PASSWORD_MODAL, + payload: config, + }); + expect(state.showDecryptPasswordModal).toBe(true); + expect(state.decryptPasswordConfig).toEqual(config); + }); + + it('dismisses the modal', () => { + const base: AppState = {...freshState(), showDecryptPasswordModal: true}; + const state = appReducer(base, { + type: AppActionTypes.DISMISS_DECRYPT_PASSWORD_MODAL, + }); + expect(state.showDecryptPasswordModal).toBe(false); + }); + + it('resets the config', () => { + const base: AppState = { + ...freshState(), + decryptPasswordConfig: {onSubmitHandler: jest.fn()} as any, + }; + const state = appReducer(base, { + type: AppActionTypes.RESET_DECRYPT_PASSWORD_CONFIG, + }); + expect(state.decryptPasswordConfig).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// SHOW/DISMISS PIN MODAL & PIN FLAGS +// --------------------------------------------------------------------------- + +describe('PIN modal and flags', () => { + it('SHOW_PIN_MODAL sets showPinModal=true and pinModalConfig', () => { + const config = {type: 'set-pin'} as any; + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_PIN_MODAL, + payload: config, + }); + expect(state.showPinModal).toBe(true); + expect(state.pinModalConfig).toEqual(config); + }); + + it('DISMISS_PIN_MODAL sets showPinModal=false and clears config', () => { + const base: AppState = { + ...freshState(), + showPinModal: true, + pinModalConfig: {type: 'set-pin'} as any, + }; + const state = appReducer(base, {type: AppActionTypes.DISMISS_PIN_MODAL}); + expect(state.showPinModal).toBe(false); + expect(state.pinModalConfig).toBeUndefined(); + }); + + it('PIN_LOCK_ACTIVE sets pinLockActive', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.PIN_LOCK_ACTIVE, + payload: true, + }); + expect(state.pinLockActive).toBe(true); + }); + + it('CURRENT_PIN sets currentPin', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.CURRENT_PIN, + payload: '1234', + }); + expect(state.currentPin).toBe('1234'); + }); + + it('CURRENT_SALT sets currentSalt', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.CURRENT_SALT, + payload: 'abc-salt', + }); + expect(state.currentSalt).toBe('abc-salt'); + }); + + it('PIN_BANNED_UNTIL sets pinBannedUntil', () => { + const ts = Date.now() + 60000; + const state = appReducer(freshState(), { + type: AppActionTypes.PIN_BANNED_UNTIL, + payload: ts, + }); + expect(state.pinBannedUntil).toBe(ts); + }); +}); + +// --------------------------------------------------------------------------- +// SHOW_BLUR / SHOW_PORTFOLIO_VALUE / TOGGLE_HIDE_ALL_BALANCES +// --------------------------------------------------------------------------- + +describe('blur and portfolio flags', () => { + it('SHOW_BLUR sets showBlur', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_BLUR, + payload: true, + }); + expect(state.showBlur).toBe(true); + }); + + it('SHOW_PORTFOLIO_VALUE sets showPortfolioValue', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_PORTFOLIO_VALUE, + payload: false, + }); + expect(state.showPortfolioValue).toBe(false); + }); + + it('TOGGLE_HIDE_ALL_BALANCES with explicit payload sets value', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.TOGGLE_HIDE_ALL_BALANCES, + payload: true, + }); + expect(state.hideAllBalances).toBe(true); + }); + + it('TOGGLE_HIDE_ALL_BALANCES without payload toggles value', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.TOGGLE_HIDE_ALL_BALANCES, + }); + // default is false, so toggling gives true + expect(state.hideAllBalances).toBe(true); + }); + + it('TOGGLE_HIDE_ALL_BALANCES toggles again from true to false', () => { + const base: AppState = {...freshState(), hideAllBalances: true}; + const state = appReducer(base, { + type: AppActionTypes.TOGGLE_HIDE_ALL_BALANCES, + }); + expect(state.hideAllBalances).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// BRAZE_INITIALIZED / BRAZE_CONTENT_CARDS_FETCHED / SET_BRAZE_EID +// --------------------------------------------------------------------------- + +describe('Braze actions', () => { + it('BRAZE_INITIALIZED sets brazeContentCardSubscription', () => { + const sub = {remove: jest.fn()} as any; + const state = appReducer(freshState(), { + type: AppActionTypes.BRAZE_INITIALIZED, + payload: {contentCardSubscription: sub}, + }); + expect(state.brazeContentCardSubscription).toEqual(sub); + }); + + it('BRAZE_CONTENT_CARDS_FETCHED updates brazeContentCards when non-empty', () => { + const cards = [{id: 'card-1'} as any]; + const state = appReducer(freshState(), { + type: AppActionTypes.BRAZE_CONTENT_CARDS_FETCHED, + payload: {contentCards: cards}, + }); + expect(state.brazeContentCards).toEqual(cards); + }); + + it('BRAZE_CONTENT_CARDS_FETCHED returns same state when both old and new are empty', () => { + const base = freshState(); // brazeContentCards: [] + const next = appReducer(base, { + type: AppActionTypes.BRAZE_CONTENT_CARDS_FETCHED, + payload: {contentCards: []}, + }); + expect(next).toBe(base); + }); + + it('SET_BRAZE_EID sets brazeEid', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_BRAZE_EID, + payload: 'eid-xyz', + }); + expect(state.brazeEid).toBe('eid-xyz'); + }); +}); + +// --------------------------------------------------------------------------- +// SHOW/DISMISS BIOMETRIC MODAL & BIOMETRIC FLAGS +// --------------------------------------------------------------------------- + +describe('BIOMETRIC_MODAL actions', () => { + it('SHOW_BIOMETRIC_MODAL sets showBiometricModal=true and config', () => { + const config = {onSubmit: jest.fn()} as any; + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_BIOMETRIC_MODAL, + payload: config, + }); + expect(state.showBiometricModal).toBe(true); + expect(state.biometricModalConfig).toEqual(config); + }); + + it('DISMISS_BIOMETRIC_MODAL sets showBiometricModal=false and clears config', () => { + const base: AppState = { + ...freshState(), + showBiometricModal: true, + biometricModalConfig: {onSubmit: jest.fn()} as any, + }; + const state = appReducer(base, { + type: AppActionTypes.DISMISS_BIOMETRIC_MODAL, + }); + expect(state.showBiometricModal).toBe(false); + expect(state.biometricModalConfig).toBeUndefined(); + }); + + it('BIOMETRIC_LOCK_ACTIVE sets biometricLockActive', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.BIOMETRIC_LOCK_ACTIVE, + payload: true, + }); + expect(state.biometricLockActive).toBe(true); + }); + + it('LOCK_AUTHORIZED_UNTIL sets lockAuthorizedUntil', () => { + const ts = Date.now() + 5000; + const state = appReducer(freshState(), { + type: AppActionTypes.LOCK_AUTHORIZED_UNTIL, + payload: ts, + }); + expect(state.lockAuthorizedUntil).toBe(ts); + }); +}); + +// --------------------------------------------------------------------------- +// SET_HOME_CAROUSEL_CONFIG / SET_HOME_CAROUSEL_LAYOUT_TYPE +// --------------------------------------------------------------------------- + +describe('home carousel config', () => { + it('SET_HOME_CAROUSEL_CONFIG replaces config when array is provided', () => { + const config = [ + {id: 'explore', show: true}, + {id: 'links', show: true}, + ] as any; + const state = appReducer(freshState(), { + type: AppActionTypes.SET_HOME_CAROUSEL_CONFIG, + payload: config, + }); + expect(state.homeCarouselConfig).toEqual(config); + }); + + it('SET_HOME_CAROUSEL_CONFIG appends a single item', () => { + const item = {id: 'links', show: true} as any; + const state = appReducer(freshState(), { + type: AppActionTypes.SET_HOME_CAROUSEL_CONFIG, + payload: item, + }); + expect(state.homeCarouselConfig).toHaveLength(1); + expect(state.homeCarouselConfig[0]).toEqual(item); + }); + + it('SET_HOME_CAROUSEL_LAYOUT_TYPE sets the layout type', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_HOME_CAROUSEL_LAYOUT_TYPE, + payload: 'listView', + }); + expect(state.homeCarouselLayoutType).toBe('listView'); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_SETTINGS_LIST_CONFIG +// --------------------------------------------------------------------------- + +describe('UPDATE_SETTINGS_LIST_CONFIG', () => { + it('adds the item when not already in the list', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.UPDATE_SETTINGS_LIST_CONFIG, + payload: 'advancedSettings' as any, + }); + expect(state.settingsListConfig).toContain('advancedSettings'); + }); + + it('removes the item when already present', () => { + const base: AppState = { + ...freshState(), + settingsListConfig: ['advancedSettings' as any], + }; + const state = appReducer(base, { + type: AppActionTypes.UPDATE_SETTINGS_LIST_CONFIG, + payload: 'advancedSettings' as any, + }); + expect(state.settingsListConfig).not.toContain('advancedSettings'); + }); +}); + +// --------------------------------------------------------------------------- +// ADD_ALT_CURRENCIES_LIST / SET_DEFAULT_ALT_CURRENCY +// --------------------------------------------------------------------------- + +describe('alt currency actions', () => { + it('ADD_ALT_CURRENCIES_LIST sets the list', () => { + const list = [{isoCode: 'EUR', name: 'Euro'}]; + const state = appReducer(freshState(), { + type: AppActionTypes.ADD_ALT_CURRENCIES_LIST, + altCurrencyList: list, + }); + expect(state.altCurrencyList).toEqual(list); + }); + + it('SET_DEFAULT_ALT_CURRENCY updates defaultAltCurrency', () => { + const currency = {isoCode: 'GBP', name: 'British Pound'}; + const state = appReducer(freshState(), { + type: AppActionTypes.SET_DEFAULT_ALT_CURRENCY, + defaultAltCurrency: currency, + }); + expect(state.defaultAltCurrency).toEqual(currency); + }); + + it('SET_DEFAULT_ALT_CURRENCY prepends to recentDefaultAltCurrency up to 3', () => { + const currencies = [ + {isoCode: 'EUR', name: 'Euro'}, + {isoCode: 'GBP', name: 'British Pound'}, + {isoCode: 'JPY', name: 'Japanese Yen'}, + ]; + + let state = freshState(); + for (const c of currencies) { + state = appReducer(state, { + type: AppActionTypes.SET_DEFAULT_ALT_CURRENCY, + defaultAltCurrency: c, + }); + } + + // Add a 4th — should still only have 3 (uniqBy + slice 0,3) + state = appReducer(state, { + type: AppActionTypes.SET_DEFAULT_ALT_CURRENCY, + defaultAltCurrency: {isoCode: 'CAD', name: 'Canadian Dollar'}, + }); + + expect(state.recentDefaultAltCurrency).toHaveLength(3); + expect(state.recentDefaultAltCurrency[0].isoCode).toBe('CAD'); + }); + + it('SET_DEFAULT_ALT_CURRENCY deduplicates the same iso code in recentDefaultAltCurrency', () => { + const base: AppState = { + ...freshState(), + recentDefaultAltCurrency: [{isoCode: 'EUR', name: 'Euro'}], + }; + const state = appReducer(base, { + type: AppActionTypes.SET_DEFAULT_ALT_CURRENCY, + defaultAltCurrency: {isoCode: 'EUR', name: 'Euro'}, + }); + expect(state.recentDefaultAltCurrency).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// SET_DEFAULT_CHAIN_FILTER_OPTION / SET_LOCAL_CHAIN_FILTER_OPTION +// --------------------------------------------------------------------------- + +describe('chain filter options', () => { + it('SET_DEFAULT_CHAIN_FILTER_OPTION sets selectedChainFilterOption', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_DEFAULT_CHAIN_FILTER_OPTION, + selectedChainFilterOption: 'eth' as any, + }); + expect(state.selectedChainFilterOption).toBe('eth'); + }); + + it('SET_DEFAULT_CHAIN_FILTER_OPTION with undefined clears selectedChainFilterOption', () => { + const base: AppState = { + ...freshState(), + selectedChainFilterOption: 'eth' as any, + }; + const state = appReducer(base, { + type: AppActionTypes.SET_DEFAULT_CHAIN_FILTER_OPTION, + selectedChainFilterOption: undefined, + }); + expect(state.selectedChainFilterOption).toBeUndefined(); + }); + + it('SET_LOCAL_CHAIN_FILTER_OPTION sets selectedLocalChainFilterOption', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_LOCAL_CHAIN_FILTER_OPTION, + selectedLocalChainFilterOption: 'btc' as any, + }); + expect(state.selectedLocalChainFilterOption).toBe('btc'); + }); + + it('SET_LOCAL_ASSETS_DROPDOWN sets selectedLocalAssetsDropdown', () => { + const dropdown = {label: 'All', value: undefined} as any; + const state = appReducer(freshState(), { + type: AppActionTypes.SET_LOCAL_ASSETS_DROPDOWN, + selectedLocalAssetsDropdown: dropdown, + }); + expect(state.selectedLocalAssetsDropdown).toEqual(dropdown); + }); +}); + +// --------------------------------------------------------------------------- +// Migration flags +// --------------------------------------------------------------------------- + +describe('migration flags', () => { + it('SET_MIGRATION_COMPLETE sets migrationComplete=true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_MIGRATION_COMPLETE, + }); + expect(state.migrationComplete).toBe(true); + }); + + it('SET_EDDSA_KEY_MIGRATION_COMPLETE sets EDDSAKeyMigrationCompleteV2=true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_EDDSA_KEY_MIGRATION_COMPLETE, + }); + expect(state.EDDSAKeyMigrationCompleteV2).toBe(true); + }); + + it('SET_KEY_MIGRATION_FAILURE sets keyMigrationFailure=true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_KEY_MIGRATION_FAILURE, + }); + expect(state.keyMigrationFailure).toBe(true); + }); + + it('SET_MIGRATION_MMKV_STORAGE_COMPLETE sets migrationMMKVStorageComplete=true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_MIGRATION_MMKV_STORAGE_COMPLETE, + }); + expect(state.migrationMMKVStorageComplete).toBe(true); + }); + + it('SET_KEY_MIGRATION_MMKV_STORAGE_FAILURE sets migrationMMKVStorageFailure=true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_KEY_MIGRATION_MMKV_STORAGE_FAILURE, + }); + expect(state.migrationMMKVStorageFailure).toBe(true); + }); + + it('SET_SHOW_KEY_MIGRATION_FAILURE_MODAL sets showKeyMigrationFailureModal', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_SHOW_KEY_MIGRATION_FAILURE_MODAL, + payload: true, + }); + expect(state.showKeyMigrationFailureModal).toBe(true); + }); + + it('SET_KEY_MIGRATION_FAILURE_MODAL_HAS_BEEN_SHOWN sets flag=true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_KEY_MIGRATION_FAILURE_MODAL_HAS_BEEN_SHOWN, + }); + expect(state.keyMigrationFailureModalHasBeenShown).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// ACTIVE_MODAL_UPDATED / FAILED_APP_INIT / CHECKING_BIOMETRIC_FOR_SENDING +// --------------------------------------------------------------------------- + +describe('misc flags', () => { + it('ACTIVE_MODAL_UPDATED sets activeModalId', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.ACTIVE_MODAL_UPDATED, + payload: 'pin', + }); + expect(state.activeModalId).toBe('pin'); + }); + + it('ACTIVE_MODAL_UPDATED can clear activeModalId to null', () => { + const base: AppState = {...freshState(), activeModalId: 'pin'}; + const state = appReducer(base, { + type: AppActionTypes.ACTIVE_MODAL_UPDATED, + payload: null, + }); + expect(state.activeModalId).toBeNull(); + }); + + it('FAILED_APP_INIT sets failedAppInit', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.FAILED_APP_INIT, + payload: true, + }); + expect(state.failedAppInit).toBe(true); + }); + + it('CHECKING_BIOMETRIC_FOR_SENDING sets the flag', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.CHECKING_BIOMETRIC_FOR_SENDING, + payload: true, + }); + expect(state.checkingBiometricForSending).toBe(true); + }); + + it('SET_HAS_VIEWED_ZENLEDGER_WARNING sets hasViewedZenLedgerWarning=true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_HAS_VIEWED_ZENLEDGER_WARNING, + }); + expect(state.hasViewedZenLedgerWarning).toBe(true); + }); + + it('SET_HAS_VIEWED_BILLS_TAB sets hasViewedBillsTab=true', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SET_HAS_VIEWED_BILLS_TAB, + }); + expect(state.hasViewedBillsTab).toBe(true); + }); + + it('USER_FEEDBACK sets userFeedback', () => { + const feedback = { + time: 1234, + version: '1.0', + sent: true, + rate: 'love' as any, + }; + const state = appReducer(freshState(), { + type: AppActionTypes.USER_FEEDBACK, + payload: feedback, + }); + expect(state.userFeedback).toEqual(feedback); + }); + + it('IN_APP_BROWSER_OPEN sets inAppBrowserOpen', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.IN_APP_BROWSER_OPEN, + payload: true, + }); + expect(state.inAppBrowserOpen).toBe(true); + }); + + it('SHOW_ARCHAX_BANNER sets showArchaxBanner', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.SHOW_ARCHAX_BANNER, + payload: true, + }); + expect(state.showArchaxBanner).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// DISMISS_MARKETING_CONTENT_CARD +// --------------------------------------------------------------------------- + +describe('DISMISS_MARKETING_CONTENT_CARD', () => { + it('adds the card ID to dismissedMarketingCardIds', () => { + const state = appReducer(freshState(), { + type: AppActionTypes.DISMISS_MARKETING_CONTENT_CARD, + payload: 'card-abc', + }); + expect(state.dismissedMarketingCardIds).toContain('card-abc'); + }); + + it('does not duplicate a card ID that is already dismissed', () => { + const base: AppState = { + ...freshState(), + dismissedMarketingCardIds: ['card-abc'], + }; + const state = appReducer(base, { + type: AppActionTypes.DISMISS_MARKETING_CONTENT_CARD, + payload: 'card-abc', + }); + // returns same state reference since it's already there + expect(state).toBe(base); + expect(state.dismissedMarketingCardIds).toHaveLength(1); + }); + + it('returns same state if payload is falsy', () => { + const base = freshState(); + const state = appReducer(base, { + type: AppActionTypes.DISMISS_MARKETING_CONTENT_CARD, + payload: '' as any, + }); + expect(state).toBe(base); + }); + + it('can accumulate multiple different card IDs', () => { + let state = freshState(); + state = appReducer(state, { + type: AppActionTypes.DISMISS_MARKETING_CONTENT_CARD, + payload: 'card-1', + }); + state = appReducer(state, { + type: AppActionTypes.DISMISS_MARKETING_CONTENT_CARD, + payload: 'card-2', + }); + expect(state.dismissedMarketingCardIds).toEqual(['card-1', 'card-2']); + }); +}); diff --git a/src/store/backup/fs-backup.spec.ts b/src/store/backup/fs-backup.spec.ts new file mode 100644 index 0000000000..5bcd53d805 --- /dev/null +++ b/src/store/backup/fs-backup.spec.ts @@ -0,0 +1,293 @@ +/** + * Tests for src/store/backup/fs-backup.ts + * + * react-native-fs is fully mocked in test/setup.js (all methods are jest.fn()). + * Sentry is also mocked in setup.js. + * + * The module has a module-level `cachedBackupExists` boolean. We use + * jest.isolateModules (with helper-methods mocked to avoid the bwc/bitcore + * chain) to get a fresh module instance per-test. + */ +import RNFS from 'react-native-fs'; + +// Mock only what fs-backup needs from helper-methods. Using requireActual here +// would pull in the bwc/bitcore-lib chain and cause duplicate-instance errors +// when jest.isolateModules re-requires the module. +jest.mock('../../utils/helper-methods', () => ({ + getErrorString: jest.fn((err: any) => + err instanceof Error ? err.message : String(err), + ), + sleep: jest.fn(() => Promise.resolve()), +})); + +// Mock LogActions and initLogs so the module loads without a Redux store +jest.mock('../../store/log', () => ({ + LogActions: { + persistLog: jest.fn(a => a), + error: jest.fn((msg: string) => ({type: 'LOG/ERROR', payload: msg})), + }, +})); + +jest.mock('../../store/log/initLogs', () => ({ + add: jest.fn(), +})); + +const mockedRNFS = RNFS as jest.Mocked; + +// ───────────────────────────────────────────────────────────────────────────── +// Helper: get a fresh module instance (resets module-level cachedBackupExists) +// ───────────────────────────────────────────────────────────────────────────── +function getFreshModule(): typeof import('./fs-backup') { + let mod: typeof import('./fs-backup'); + jest.isolateModules(() => { + mod = require('./fs-backup'); + }); + return mod!; +} + +// ───────────────────────────────────────────────────────────────────────────── +// backupFileExists +// ───────────────────────────────────────────────────────────────────────────── + +describe('backupFileExists', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns true when the file exists', async () => { + const {backupFileExists} = getFreshModule(); + (mockedRNFS.exists as jest.Mock).mockResolvedValueOnce(true); + expect(await backupFileExists()).toBe(true); + }); + + it('returns false when the file does not exist', async () => { + const {backupFileExists} = getFreshModule(); + (mockedRNFS.exists as jest.Mock).mockResolvedValueOnce(false); + expect(await backupFileExists()).toBe(false); + }); + + it('returns false when RNFS.exists throws', async () => { + const {backupFileExists} = getFreshModule(); + (mockedRNFS.exists as jest.Mock).mockRejectedValueOnce( + new Error('fs error'), + ); + expect(await backupFileExists()).toBe(false); + }); + + it('returns true from cache on second call without hitting RNFS again', async () => { + const {backupFileExists} = getFreshModule(); + (mockedRNFS.exists as jest.Mock).mockResolvedValueOnce(true); + await backupFileExists(); // sets cachedBackupExists = true + jest.clearAllMocks(); + const result = await backupFileExists(); // should short-circuit via cache + expect(result).toBe(true); + expect(mockedRNFS.exists).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// backupPersistRoot +// ───────────────────────────────────────────────────────────────────────────── + +describe('backupPersistRoot', () => { + beforeEach(() => { + jest.clearAllMocks(); + (mockedRNFS.exists as jest.Mock).mockResolvedValue(false); + (mockedRNFS.writeFile as jest.Mock).mockResolvedValue(undefined); + (mockedRNFS.moveFile as jest.Mock).mockResolvedValue(undefined); + (mockedRNFS.unlink as jest.Mock).mockResolvedValue(undefined); + (mockedRNFS.mkdir as jest.Mock).mockResolvedValue(undefined); + }); + + it('strips MARKET_STATS, PORTFOLIO, RATE, SHOP_CATALOG and keeps other fields', async () => { + const {backupPersistRoot} = getFreshModule(); + const raw = JSON.stringify({ + MARKET_STATS: {a: 1}, + PORTFOLIO: {b: 2}, + RATE: {c: 3}, + SHOP_CATALOG: {d: 4}, + WALLET: {keys: {}}, + }); + (mockedRNFS.exists as jest.Mock).mockResolvedValue(false); + await backupPersistRoot(raw); + + expect(mockedRNFS.writeFile).toHaveBeenCalledTimes(1); + const written = JSON.parse( + (mockedRNFS.writeFile as jest.Mock).mock.calls[0][1], + ); + expect(written.MARKET_STATS).toBeUndefined(); + expect(written.PORTFOLIO).toBeUndefined(); + expect(written.RATE).toBeUndefined(); + expect(written.SHOP_CATALOG).toBeUndefined(); + expect(written.WALLET).toEqual({keys: {}}); + }); + + it('writes raw JSON unchanged when JSON.parse fails', async () => { + const {backupPersistRoot} = getFreshModule(); + const rawJson = 'not valid json {{{}}}'; + (mockedRNFS.exists as jest.Mock).mockResolvedValue(false); + await backupPersistRoot(rawJson); + + expect(mockedRNFS.writeFile).toHaveBeenCalledTimes(1); + expect((mockedRNFS.writeFile as jest.Mock).mock.calls[0][1]).toBe(rawJson); + }); + + it('creates the directory when it does not exist', async () => { + const {backupPersistRoot} = getFreshModule(); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(false) // ensureDir: BASE_DIR not exists → mkdir + .mockResolvedValue(false); // final file does not exist + await backupPersistRoot('{}'); + expect(mockedRNFS.mkdir).toHaveBeenCalledTimes(1); + }); + + it('skips mkdir when the directory already exists', async () => { + const {backupPersistRoot} = getFreshModule(); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(true) // ensureDir: dir exists + .mockResolvedValue(false); // no final file + await backupPersistRoot('{}'); + expect(mockedRNFS.mkdir).not.toHaveBeenCalled(); + }); + + it('rotates final→backup and moves temp→final when final exists but backup does not', async () => { + const {backupPersistRoot} = getFreshModule(); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(true) // ensureDir: dir exists + .mockResolvedValueOnce(true) // finalExists = true + .mockResolvedValueOnce(false); // bakExists = false → no unlink + await backupPersistRoot('{}'); + expect(mockedRNFS.unlink).not.toHaveBeenCalled(); + expect(mockedRNFS.moveFile).toHaveBeenCalledTimes(2); // FINAL→BAK, TEMP→FINAL + }); + + it('unlinks old backup before rotating when both final and backup exist', async () => { + const {backupPersistRoot} = getFreshModule(); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(true) // ensureDir: dir exists + .mockResolvedValueOnce(true) // finalExists = true + .mockResolvedValueOnce(true); // bakExists = true → unlink + await backupPersistRoot('{}'); + expect(mockedRNFS.unlink).toHaveBeenCalledTimes(1); + expect(mockedRNFS.moveFile).toHaveBeenCalledTimes(2); + }); + + it('still moves temp→final even when the final→backup rotation throws', async () => { + const {backupPersistRoot} = getFreshModule(); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(true) // ensureDir: dir exists + .mockResolvedValueOnce(true) // finalExists + .mockResolvedValueOnce(false); // bakExists = false + (mockedRNFS.moveFile as jest.Mock) + .mockRejectedValueOnce(new Error('rotate failed')) + .mockResolvedValueOnce(undefined); + await backupPersistRoot('{}'); + expect(mockedRNFS.moveFile).toHaveBeenCalledTimes(2); + }); + + it('cleans up temp file when writeFile throws', async () => { + const {backupPersistRoot} = getFreshModule(); + (mockedRNFS.writeFile as jest.Mock).mockRejectedValueOnce( + new Error('write error'), + ); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(true) // ensureDir: dir exists + .mockResolvedValueOnce(true); // tmpExists = true → unlink temp + await backupPersistRoot('{}'); + expect(mockedRNFS.unlink).toHaveBeenCalledTimes(1); + }); + + it('does not throw even if temp file cleanup also fails', async () => { + const {backupPersistRoot} = getFreshModule(); + (mockedRNFS.writeFile as jest.Mock).mockRejectedValueOnce( + new Error('write error'), + ); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(true) // ensureDir: dir exists + .mockResolvedValueOnce(true); // tmpExists = true + (mockedRNFS.unlink as jest.Mock).mockRejectedValueOnce( + new Error('unlink error'), + ); + await expect(backupPersistRoot('{}')).resolves.toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// readBackupPersistRoot +// ───────────────────────────────────────────────────────────────────────────── + +describe('readBackupPersistRoot', () => { + beforeEach(() => { + jest.clearAllMocks(); + (mockedRNFS.exists as jest.Mock).mockResolvedValue(false); + }); + + it('returns valid JSON data from the final file', async () => { + const {readBackupPersistRoot} = getFreshModule(); + const jsonStr = '{"WALLET":{"keys":{}}}'; + (mockedRNFS.exists as jest.Mock).mockResolvedValueOnce(true); + (mockedRNFS.readFile as jest.Mock).mockResolvedValueOnce(jsonStr); + expect(await readBackupPersistRoot()).toBe(jsonStr); + }); + + it('falls through to backup when final file contains invalid JSON', async () => { + const {readBackupPersistRoot} = getFreshModule(); + const bakJson = '{"WALLET":{}}'; + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(true) // final exists + .mockResolvedValueOnce(true); // bak exists + (mockedRNFS.readFile as jest.Mock) + .mockResolvedValueOnce('not valid json') + .mockResolvedValueOnce(bakJson); + expect(await readBackupPersistRoot()).toBe(bakJson); + }); + + it('returns null when final read throws and backup does not exist', async () => { + const {readBackupPersistRoot} = getFreshModule(); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(true) // final exists + .mockResolvedValueOnce(false); // bak does not exist + (mockedRNFS.readFile as jest.Mock).mockRejectedValueOnce( + new Error('read error'), + ); + expect(await readBackupPersistRoot()).toBeNull(); + }); + + it('returns null when backup file data is also invalid JSON', async () => { + const {readBackupPersistRoot} = getFreshModule(); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(true) // final exists + .mockResolvedValueOnce(true); // bak exists + (mockedRNFS.readFile as jest.Mock) + .mockResolvedValueOnce('bad json') + .mockResolvedValueOnce('also bad'); + expect(await readBackupPersistRoot()).toBeNull(); + }); + + it('returns null when neither final nor backup file exists', async () => { + const {readBackupPersistRoot} = getFreshModule(); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(false) // final does not exist + .mockResolvedValueOnce(false); // bak does not exist + expect(await readBackupPersistRoot()).toBeNull(); + }); + + it('returns null when final does not exist and backup read throws', async () => { + const {readBackupPersistRoot} = getFreshModule(); + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(false) // final does not exist + .mockResolvedValueOnce(true); // bak exists + (mockedRNFS.readFile as jest.Mock).mockRejectedValueOnce( + new Error('bak read error'), + ); + expect(await readBackupPersistRoot()).toBeNull(); + }); + + it('returns valid JSON from backup when final does not exist', async () => { + const {readBackupPersistRoot} = getFreshModule(); + const bakJson = '{"keys":{"k1":{}}}'; + (mockedRNFS.exists as jest.Mock) + .mockResolvedValueOnce(false) // final does not exist + .mockResolvedValueOnce(true); // bak exists + (mockedRNFS.readFile as jest.Mock).mockResolvedValueOnce(bakJson); + expect(await readBackupPersistRoot()).toBe(bakJson); + }); +}); diff --git a/src/store/bitpay-id/bitpay-id.effects.spec.ts b/src/store/bitpay-id/bitpay-id.effects.spec.ts new file mode 100644 index 0000000000..381334d0ce --- /dev/null +++ b/src/store/bitpay-id/bitpay-id.effects.spec.ts @@ -0,0 +1,623 @@ +/** + * Tests for bitpay-id.effects.ts + * + * Covers: + * - startFetchSession (success + failure) + * - startBitPayIdStoreInit (dispatches SUCCESS_INITIALIZE_STORE) + * - startBitPayIdAnalyticsInit (Braze merge branch, no-op when user is falsy) + * - checkLoginWithPasskey (no email, passkey false, passkey true, error 1001, other error) + * - startSubmitForgotPasswordEmail (success, failed data, exception) + * - startTwoFactorAuth (success, failure) + * - startDisconnectBitPayId (authenticated + unauthenticated paths) + * - startFetchBasicInfo (success, failure) + * - startFetchDoshToken (success, failure) + */ + +import configureTestStore from '@test/store'; +import {Network} from '../../constants'; +import {BitPayIdActionTypes} from './bitpay-id.types'; +import { + startFetchSession, + startBitPayIdStoreInit, + startBitPayIdAnalyticsInit, + checkLoginWithPasskey, + startSubmitForgotPasswordEmail, + startTwoFactorAuth, + startDisconnectBitPayId, + startFetchBasicInfo, + startFetchDoshToken, +} from './bitpay-id.effects'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +jest.mock('../../managers/LogManager', () => ({ + logManager: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../../managers/OngoingProcessManager', () => ({ + ongoingProcessManager: { + show: jest.fn(), + hide: jest.fn(), + }, +})); + +jest.mock('../../lib/dosh', () => ({ + __esModule: true, + default: {clearUser: jest.fn()}, +})); + +jest.mock('../../lib/Mixpanel', () => ({ + MixpanelWrapper: { + reset: jest.fn(() => Promise.resolve()), + identify: jest.fn(() => Promise.resolve()), + }, +})); + +jest.mock('../../lib/Braze', () => ({ + BrazeWrapper: { + merge: jest.fn(() => Promise.resolve()), + identify: jest.fn(() => Promise.resolve()), + setEmail: jest.fn(), + setEmailNotificationSubscriptionType: jest.fn(), + }, +})); + +jest.mock('../analytics/analytics.effects', () => ({ + Analytics: { + track: jest.fn(() => ({type: 'ANALYTICS/TRACK'})), + identify: jest.fn(() => () => Promise.resolve()), + startMergingUser: jest.fn(), + }, +})); + +jest.mock('../app/app.effects', () => ({ + isAnonymousBrazeEid: jest.fn(() => false), + setEmailNotifications: jest.fn(() => ({type: 'APP/SET_EMAIL_NOTIFICATIONS'})), +})); + +jest.mock('../shop', () => ({ + ShopEffects: { + startFetchCatalog: jest.fn(() => ({type: 'SHOP/FETCH_CATALOG'})), + startSyncGiftCards: jest.fn(() => () => Promise.resolve()), + redeemSyncedGiftCards: jest.fn(() => ({type: 'SHOP/REDEEM'})), + startGetBillPayAccounts: jest.fn(() => () => Promise.resolve()), + }, + ShopActions: { + clearedBillPayAccounts: jest.fn(() => ({type: 'SHOP/CLEAR_BILL_ACCOUNTS'})), + clearedBillPayPayments: jest.fn(() => ({type: 'SHOP/CLEAR_BILL_PAYMENTS'})), + }, +})); + +jest.mock('../card', () => ({ + CardEffects: { + startCardStoreInit: jest.fn(() => ({type: 'CARD/INIT'})), + }, + CardActions: { + isJoinedWaitlist: jest.fn(() => ({type: 'CARD/WAITLIST'})), + }, +})); + +jest.mock('../../utils/passkey', () => ({ + getPasskeyStatus: jest.fn(), + signInWithPasskey: jest.fn(), + getPasskeyCredentials: jest.fn(() => Promise.resolve({credentials: []})), +})); + +jest.mock('../../utils/cookieAuth', () => ({ + clearAllCookiesEverywhere: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../../api/auth', () => ({ + __esModule: true, + default: { + fetchSession: jest.fn(), + login: jest.fn(), + logout: jest.fn(), + register: jest.fn(), + generatePairingCode: jest.fn(), + pair: jest.fn(), + submitTwoFactor: jest.fn(), + submitForgotPasswordEmail: jest.fn(), + }, +})); + +jest.mock('../../api/user', () => ({ + __esModule: true, + default: { + fetchInitialUserData: jest.fn(), + fetchBasicInfo: jest.fn(), + fetchDoshToken: jest.fn(), + }, +})); + +jest.mock('../../api/bitpay', () => ({ + __esModule: true, + default: { + apiCall: jest.fn(), + getInstance: jest.fn(() => ({ + request: jest.fn(() => Promise.resolve({data: {data: {}}})), + })), + }, +})); + +import AuthApi from '../../api/auth'; +import UserApi from '../../api/user'; +import {getPasskeyStatus, signInWithPasskey} from '../../utils/passkey'; +import {isAnonymousBrazeEid} from '../app/app.effects'; +import {BrazeWrapper} from '../../lib/Braze'; + +const MockAuthApi = AuthApi as jest.Mocked; +const MockUserApi = UserApi as jest.Mocked; +const MockGetPasskeyStatus = getPasskeyStatus as jest.Mock; +const MockSignInWithPasskey = signInWithPasskey as jest.Mock; +const MockIsAnonymousBrazeEid = isAnonymousBrazeEid as jest.Mock; +const MockBrazeWrapperMerge = BrazeWrapper.merge as jest.Mock; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeSession = (overrides = {}) => ({ + csrfToken: 'csrf-token-123', + isAuthenticated: false, + captchaKey: '', + noCaptchaKey: '', + ...overrides, +}); + +const makeUser = (overrides = {}): any => ({ + eid: 'eid-abc', + name: 'Alice Doe', + email: 'alice@example.com', + givenName: 'Alice', + familyName: 'Doe', + optInEmailMarketing: true, + verified: true, + ...overrides, +}); + +const makeInitialData = (userOverrides = {}): any => ({ + basicInfo: makeUser(userOverrides), + doshToken: null, +}); + +const baseStore = () => + configureTestStore({ + BITPAY_ID: { + session: makeSession({csrfToken: 'csrf-token-123'}), + apiToken: {[Network.mainnet]: 'token-abc'}, + }, + APP: { + network: Network.mainnet, + brazeEid: null, + emailNotifications: {accepted: false}, + notificationsAccepted: false, + }, + }); + +// --------------------------------------------------------------------------- +// startFetchSession +// --------------------------------------------------------------------------- + +describe('startFetchSession', () => { + beforeEach(() => jest.clearAllMocks()); + + it('dispatches successFetchSession on success', async () => { + const session = makeSession({ + csrfToken: 'new-token', + isAuthenticated: true, + }); + (MockAuthApi.fetchSession as jest.Mock).mockResolvedValueOnce(session); + + const store = baseStore(); + await store.dispatch(startFetchSession()); + + const actions = (store as any).getActions?.() ?? []; + const sessionInState = store.getState().BITPAY_ID.session; + // Either via recorded actions or final state + const succeeded = + actions.some( + (a: any) => a.type === BitPayIdActionTypes.SUCCESS_FETCH_SESSION, + ) || sessionInState.csrfToken === 'new-token'; + expect(succeeded).toBe(true); + }); + + it('dispatches failedFetchSession when AuthApi throws', async () => { + (MockAuthApi.fetchSession as jest.Mock).mockRejectedValueOnce( + new Error('network error'), + ); + + const store = baseStore(); + await store.dispatch(startFetchSession()); + // The fetchSessionStatus transitions to 'loading' and then fails → check state + // failedFetchSession sets fetchSessionStatus to 'failed' + expect(store.getState().BITPAY_ID.fetchSessionStatus).toBe('failed'); + }); +}); + +// --------------------------------------------------------------------------- +// startBitPayIdStoreInit +// --------------------------------------------------------------------------- + +describe('startBitPayIdStoreInit', () => { + beforeEach(() => jest.clearAllMocks()); + + it('dispatches SUCCESS_INITIALIZE_STORE with network and user data', async () => { + const store = baseStore(); + const initialData = makeInitialData(); + + await store.dispatch(startBitPayIdStoreInit(initialData)); + + const state = store.getState().BITPAY_ID; + // The user should be populated for the mainnet network + expect(state.user[Network.mainnet]).toBeDefined(); + expect(state.user[Network.mainnet]?.eid).toBe('eid-abc'); + }); + + it('populates user in state and handles marketing communications flag', async () => { + const store = baseStore(); + const initialData = makeInitialData(); + + await store.dispatch(startBitPayIdStoreInit(initialData, true)); + + // Even with marketing flag set, user should still be initialized + const state = store.getState().BITPAY_ID; + expect(state.user[Network.mainnet]?.email).toBe('alice@example.com'); + }); +}); + +// --------------------------------------------------------------------------- +// startBitPayIdAnalyticsInit +// --------------------------------------------------------------------------- + +describe('startBitPayIdAnalyticsInit', () => { + beforeEach(() => jest.clearAllMocks()); + + it('does nothing when user is falsy', async () => { + const store = baseStore(); + await store.dispatch(startBitPayIdAnalyticsInit(null as any)); + expect(MockBrazeWrapperMerge).not.toHaveBeenCalled(); + }); + + it('calls BrazeWrapper.merge when brazeEid is anonymous and differs from user eid', async () => { + MockIsAnonymousBrazeEid.mockReturnValueOnce(true); + + const store = configureTestStore({ + BITPAY_ID: { + session: makeSession(), + apiToken: {[Network.mainnet]: ''}, + }, + APP: { + network: Network.mainnet, + brazeEid: 'old-anon-eid', // different from user eid + emailNotifications: {accepted: false}, + notificationsAccepted: false, + }, + }); + + const user = makeUser({eid: 'new-eid-xyz'}); + await store.dispatch(startBitPayIdAnalyticsInit(user)); + + expect(MockBrazeWrapperMerge).toHaveBeenCalledWith( + 'old-anon-eid', + 'new-eid-xyz', + ); + }); + + it('does NOT call BrazeWrapper.merge when brazeEid is not anonymous', async () => { + MockIsAnonymousBrazeEid.mockReturnValueOnce(false); + + const store = configureTestStore({ + APP: { + network: Network.mainnet, + brazeEid: 'registered-eid', + emailNotifications: {accepted: false}, + notificationsAccepted: false, + }, + BITPAY_ID: { + session: makeSession(), + apiToken: {[Network.mainnet]: ''}, + }, + }); + + await store.dispatch(startBitPayIdAnalyticsInit(makeUser())); + expect(MockBrazeWrapperMerge).not.toHaveBeenCalled(); + }); + + it('derives givenName/familyName from name when they are missing', async () => { + const user = makeUser({ + givenName: undefined, + familyName: undefined, + name: 'John Smith', + }); + const store = baseStore(); + // Should not throw + await expect( + store.dispatch(startBitPayIdAnalyticsInit(user)), + ).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// checkLoginWithPasskey +// --------------------------------------------------------------------------- + +describe('checkLoginWithPasskey', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns false immediately when email is undefined (no passkey check, passkey sign-in resolves false → rejects)', async () => { + // When email is undefined: getPasskeyStatus is skipped, _passkey stays false, + // `email && !_passkey` is also false (email is falsy), so it falls through to + // signInWithPasskey(). We mock it to return true to avoid the rejection branch. + MockSignInWithPasskey.mockResolvedValueOnce(true); + const store = baseStore(); + const result = await store.dispatch( + checkLoginWithPasskey(undefined, Network.mainnet, 'csrf'), + ); + expect(result).toBe(true); + expect(MockGetPasskeyStatus).not.toHaveBeenCalled(); + }); + + it('returns false when user has no passkey', async () => { + MockGetPasskeyStatus.mockResolvedValueOnce({passkey: false}); + const store = baseStore(); + const result = await store.dispatch( + checkLoginWithPasskey('alice@example.com', Network.mainnet, 'csrf'), + ); + expect(result).toBe(false); + }); + + it('returns true when passkey sign-in succeeds', async () => { + MockGetPasskeyStatus.mockResolvedValueOnce({passkey: true}); + MockSignInWithPasskey.mockResolvedValueOnce(true); + const store = baseStore(); + const result = await store.dispatch( + checkLoginWithPasskey('alice@example.com', Network.mainnet, 'csrf'), + ); + expect(result).toBe(true); + }); + + it('rejects when signInWithPasskey returns false (failed sign-in)', async () => { + MockGetPasskeyStatus.mockResolvedValueOnce({passkey: true}); + MockSignInWithPasskey.mockResolvedValueOnce(false); + const store = baseStore(); + await expect( + store.dispatch( + checkLoginWithPasskey('alice@example.com', Network.mainnet, 'csrf'), + ), + ).rejects.toThrow('Failed to sign in with Passkey'); + }); + + it('returns false (no error thrown) when passkey error includes "error 1001" (user cancelled)', async () => { + MockGetPasskeyStatus.mockResolvedValueOnce({passkey: true}); + MockSignInWithPasskey.mockRejectedValueOnce( + new Error('Passkey error 1001: cancelled'), + ); + const store = baseStore(); + const result = await store.dispatch( + checkLoginWithPasskey('alice@example.com', Network.mainnet, 'csrf'), + ); + expect(result).toBe(false); + }); + + it('rejects when passkey sign-in throws a non-1001 error', async () => { + MockGetPasskeyStatus.mockResolvedValueOnce({passkey: true}); + MockSignInWithPasskey.mockRejectedValueOnce( + new Error('Unexpected passkey failure'), + ); + const store = baseStore(); + await expect( + store.dispatch( + checkLoginWithPasskey('alice@example.com', Network.mainnet, 'csrf'), + ), + ).rejects.toThrow('Unexpected passkey failure'); + }); +}); + +// --------------------------------------------------------------------------- +// startSubmitForgotPasswordEmail +// --------------------------------------------------------------------------- + +describe('startSubmitForgotPasswordEmail', () => { + beforeEach(() => jest.clearAllMocks()); + + it('dispatches success status when API returns data.success = true', async () => { + (MockAuthApi.submitForgotPasswordEmail as jest.Mock).mockResolvedValueOnce({ + success: true, + }); + const store = baseStore(); + await store.dispatch( + startSubmitForgotPasswordEmail({email: 'alice@example.com'}), + ); + const status = store.getState().BITPAY_ID.forgotPasswordEmailStatus; + expect(status?.status).toBe('success'); + }); + + it('dispatches failed status when API returns data.success = false', async () => { + (MockAuthApi.submitForgotPasswordEmail as jest.Mock).mockResolvedValueOnce({ + success: false, + message: 'Something went wrong', + }); + const store = baseStore(); + await store.dispatch( + startSubmitForgotPasswordEmail({email: 'alice@example.com'}), + ); + const status = store.getState().BITPAY_ID.forgotPasswordEmailStatus; + expect(status?.status).toBe('failed'); + expect(status?.message).toBe('Something went wrong'); + }); + + it('dispatches failed status when API throws', async () => { + (MockAuthApi.submitForgotPasswordEmail as jest.Mock).mockRejectedValueOnce( + new Error('network error'), + ); + const store = baseStore(); + await store.dispatch( + startSubmitForgotPasswordEmail({email: 'alice@example.com'}), + ); + const status = store.getState().BITPAY_ID.forgotPasswordEmailStatus; + expect(status?.status).toBe('failed'); + }); +}); + +// --------------------------------------------------------------------------- +// startTwoFactorAuth +// --------------------------------------------------------------------------- + +describe('startTwoFactorAuth', () => { + beforeEach(() => jest.clearAllMocks()); + + it('dispatches successSubmitTwoFactorAuth on successful 2FA submission', async () => { + (MockAuthApi.submitTwoFactor as jest.Mock).mockResolvedValueOnce({}); + const session = makeSession({csrfToken: 'new-csrf', isAuthenticated: true}); + (MockAuthApi.fetchSession as jest.Mock).mockResolvedValueOnce(session); + + const store = baseStore(); + await store.dispatch(startTwoFactorAuth('123456')); + + expect(store.getState().BITPAY_ID.twoFactorAuthStatus).toBe('success'); + }); + + it('dispatches failedSubmitTwoFactorAuth on API error', async () => { + (MockAuthApi.submitTwoFactor as jest.Mock).mockRejectedValueOnce( + new Error('Invalid code'), + ); + const store = baseStore(); + await store.dispatch(startTwoFactorAuth('wrong-code')); + expect(store.getState().BITPAY_ID.twoFactorAuthStatus).toBe('failed'); + expect(store.getState().BITPAY_ID.twoFactorAuthError).toBe('Invalid code'); + }); + + it('uses the Axios error response data as error message', async () => { + const axiosErr: any = { + isAxiosError: true, + response: {data: 'Bad two-factor code'}, + message: 'Request failed', + }; + (MockAuthApi.submitTwoFactor as jest.Mock).mockRejectedValueOnce(axiosErr); + const store = baseStore(); + await store.dispatch(startTwoFactorAuth('000000')); + const errMsg = store.getState().BITPAY_ID.twoFactorAuthError; + // upperFirst('Bad two-factor code') = 'Bad two-factor code' + expect(errMsg).toBe('Bad two-factor code'); + }); +}); + +// --------------------------------------------------------------------------- +// startDisconnectBitPayId +// --------------------------------------------------------------------------- + +describe('startDisconnectBitPayId', () => { + beforeEach(() => jest.clearAllMocks()); + + it('calls AuthApi.logout when session isAuthenticated=true and dispatches bitPayIdDisconnected', async () => { + (MockAuthApi.fetchSession as jest.Mock) + .mockResolvedValueOnce( + makeSession({isAuthenticated: true, csrfToken: 'valid-csrf'}), + ) + .mockResolvedValueOnce(makeSession()); // second call for session refresh + (MockAuthApi.logout as jest.Mock).mockResolvedValueOnce({}); + + const store = baseStore(); + await store.dispatch(startDisconnectBitPayId()); + + expect(MockAuthApi.logout).toHaveBeenCalledTimes(1); + // BITPAY_ID_DISCONNECTED clears user and token for the network + expect(store.getState().BITPAY_ID.user[Network.mainnet]).toBeNull(); + }); + + it('does NOT call AuthApi.logout when session isAuthenticated=false', async () => { + (MockAuthApi.fetchSession as jest.Mock) + .mockResolvedValueOnce(makeSession({isAuthenticated: false})) + .mockResolvedValueOnce(makeSession()); + + const store = baseStore(); + await store.dispatch(startDisconnectBitPayId()); + + expect(MockAuthApi.logout).not.toHaveBeenCalled(); + }); + + it('still completes (does not throw) when AuthApi.logout throws', async () => { + (MockAuthApi.fetchSession as jest.Mock) + .mockResolvedValueOnce( + makeSession({isAuthenticated: true, csrfToken: 'valid-csrf'}), + ) + .mockResolvedValueOnce(makeSession()); + (MockAuthApi.logout as jest.Mock).mockRejectedValueOnce( + new Error('logout failed'), + ); + + const store = baseStore(); + await expect( + store.dispatch(startDisconnectBitPayId()), + ).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// startFetchBasicInfo +// --------------------------------------------------------------------------- + +describe('startFetchBasicInfo', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns user data and dispatches successFetchBasicInfo on success', async () => { + const user = makeUser(); + (MockUserApi.fetchBasicInfo as jest.Mock).mockResolvedValueOnce(user); + + const store = baseStore(); + const result = await store.dispatch(startFetchBasicInfo('token-abc')); + + expect(result).toEqual(user); + expect(store.getState().BITPAY_ID.user[Network.mainnet]?.eid).toBe( + 'eid-abc', + ); + }); + + it('dispatches failedFetchBasicInfo and rethrows on error', async () => { + const error = new Error('fetch failed'); + (MockUserApi.fetchBasicInfo as jest.Mock).mockRejectedValueOnce(error); + + const store = baseStore(); + await expect( + store.dispatch(startFetchBasicInfo('token-abc')), + ).rejects.toThrow('fetch failed'); + expect(store.getState().BITPAY_ID.fetchBasicInfoStatus).toBe('failed'); + }); +}); + +// --------------------------------------------------------------------------- +// startFetchDoshToken +// --------------------------------------------------------------------------- + +describe('startFetchDoshToken', () => { + beforeEach(() => jest.clearAllMocks()); + + it('dispatches successFetchDoshToken with the returned token', async () => { + (MockUserApi.fetchDoshToken as jest.Mock).mockResolvedValueOnce('dosh-xyz'); + + const store = baseStore(); + await store.dispatch(startFetchDoshToken()); + + expect(store.getState().BITPAY_ID.doshToken[Network.mainnet]).toBe( + 'dosh-xyz', + ); + }); + + it('dispatches failedFetchDoshToken when API call fails', async () => { + (MockUserApi.fetchDoshToken as jest.Mock).mockRejectedValueOnce( + new Error('dosh error'), + ); + + const store = baseStore(); + await store.dispatch(startFetchDoshToken()); + expect(store.getState().BITPAY_ID.fetchDoshTokenStatus).toBe('failed'); + }); +}); diff --git a/src/store/buy-crypto/buy-crypto.effects.spec.ts b/src/store/buy-crypto/buy-crypto.effects.spec.ts new file mode 100644 index 0000000000..a276f4de5a --- /dev/null +++ b/src/store/buy-crypto/buy-crypto.effects.spec.ts @@ -0,0 +1,291 @@ +/** + * Tests for buy-crypto.effects.ts + * + * Covers: + * - calculateAltFiatToUsd + * - calculateUsdToAltFiat + * - calculateAnyFiatToAltFiat + * - roundUpNice + * - getBuyCryptoFiatLimits + */ + +import configureTestStore from '@test/store'; +import { + calculateAltFiatToUsd, + calculateUsdToAltFiat, + calculateAnyFiatToAltFiat, + roundUpNice, + getBuyCryptoFiatLimits, +} from './buy-crypto.effects'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +jest.mock('../../managers/LogManager', () => ({ + logManager: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +// Mock navigationRef to avoid native navigation calls +jest.mock('../../Root', () => ({ + navigationRef: {navigate: jest.fn(), dispatch: jest.fn()}, +})); + +// Mock analytics so goToBuyCrypto doesn't need a real Mixpanel instance +jest.mock('../analytics/analytics.effects', () => ({ + Analytics: { + track: jest.fn(() => ({type: 'ANALYTICS/TRACK'})), + }, +})); + +// Mock all exchange fiat-limit helpers so we control their return values +jest.mock('../../navigation/services/buy-crypto/utils/banxa-utils', () => ({ + getBanxaFiatAmountLimits: jest.fn(() => ({min: 35, max: 14000})), +})); +jest.mock('../../navigation/services/buy-crypto/utils/moonpay-utils', () => ({ + getMoonpayFiatAmountLimits: jest.fn(() => ({min: 25, max: 10000})), +})); +jest.mock('../../navigation/services/buy-crypto/utils/ramp-utils', () => ({ + getRampFiatAmountLimits: jest.fn(() => ({min: 20, max: 10000})), +})); +jest.mock('../../navigation/services/buy-crypto/utils/sardine-utils', () => ({ + getSardineFiatAmountLimits: jest.fn(() => ({min: 10, max: 5000})), +})); +jest.mock('../../navigation/services/buy-crypto/utils/simplex-utils', () => ({ + getSimplexFiatAmountLimits: jest.fn(() => ({min: 50, max: 20000})), +})); +jest.mock('../../navigation/services/buy-crypto/utils/transak-utils', () => ({ + getTransakFiatAmountLimits: jest.fn(() => ({min: 30, max: 15000})), +})); + +// --------------------------------------------------------------------------- +// Shared rate fixture +// btc/USD rate = 50000, btc/EUR rate = 45000 +// → 1 USD = 0.9 EUR, 1 EUR = 1.111... USD +// --------------------------------------------------------------------------- +const rateState = { + RATE: { + rates: { + btc: [ + {code: 'USD', rate: 50000}, + {code: 'EUR', rate: 45000}, + ], + }, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, +}; + +// --------------------------------------------------------------------------- +// roundUpNice (pure function — no store needed) +// --------------------------------------------------------------------------- +describe('roundUpNice', () => { + it('returns 0 for n <= 0', () => { + expect(roundUpNice(0)).toBe(0); + expect(roundUpNice(-5)).toBe(0); + }); + + it('rounds up small values to next nice number', () => { + // magnitude=1, step=0.5 → next multiple of 0.5 above 8.3 = 8.5 + expect(roundUpNice(8.3)).toBe(8.5); + // magnitude=10, step=5 → next multiple of 5 above 11 = 15 + expect(roundUpNice(11)).toBe(15); + }); + + it('rounds up hundreds', () => { + // 207.75 → magnitude 100, step 50 → next multiple of 50 above 207.75 = 250 + expect(roundUpNice(207.75)).toBe(250); + }); + + it('rounds up thousands', () => { + // 23456.45 → magnitude 10000, step 5000 → next multiple of 5000 above 23456.45 = 25000 + expect(roundUpNice(23456.45)).toBe(25000); + }); + + it('returns the value unchanged when it is already a nice number', () => { + expect(roundUpNice(500)).toBe(500); + }); +}); + +// --------------------------------------------------------------------------- +// calculateAltFiatToUsd +// --------------------------------------------------------------------------- +describe('calculateAltFiatToUsd', () => { + it('returns the same amount when currency is already USD', () => { + const store = configureTestStore(rateState); + const result = store.dispatch(calculateAltFiatToUsd(100, 'USD')); + expect(result).toBe(100); + }); + + it('converts EUR → USD using BTC rates', () => { + const store = configureTestStore(rateState); + // rateAltUsd = 50000 / 45000 ≈ 1.1111 + // 90 EUR * 1.1111 = 100 USD (approx) + const result = store.dispatch(calculateAltFiatToUsd(90, 'EUR')); + expect(result).toBeCloseTo(100, 0); + }); + + it('returns undefined when there are no rates for the currency', () => { + const store = configureTestStore(rateState); + const result = store.dispatch(calculateAltFiatToUsd(100, 'JPY')); + expect(result).toBeUndefined(); + }); + + it('is case-insensitive for currency codes', () => { + const store = configureTestStore(rateState); + const upper = store.dispatch(calculateAltFiatToUsd(90, 'EUR')); + const lower = store.dispatch(calculateAltFiatToUsd(90, 'eur')); + expect(upper).toEqual(lower); + }); +}); + +// --------------------------------------------------------------------------- +// calculateUsdToAltFiat +// --------------------------------------------------------------------------- +describe('calculateUsdToAltFiat', () => { + it('converts USD → EUR using BTC rates', () => { + const store = configureTestStore(rateState); + // rateAltUsd = 45000 / 50000 = 0.9 + // 100 USD * 0.9 = 90 EUR + const result = store.dispatch(calculateUsdToAltFiat(100, 'EUR')); + expect(result).toBeCloseTo(90, 1); + }); + + it('returns undefined when no rates exist for the target currency', () => { + const store = configureTestStore(rateState); + const result = store.dispatch(calculateUsdToAltFiat(100, 'JPY')); + expect(result).toBeUndefined(); + }); + + it('respects a custom decimalPrecision', () => { + const store = configureTestStore(rateState); + // 100 USD → EUR with precision 4 + const result = store.dispatch(calculateUsdToAltFiat(100, 'EUR', 4)); + // 100 * (45000/50000) = 90.0000 + expect(result).toBe(90); + }); + + it('returns undefined when rates object has no btc entry', () => { + const store = configureTestStore({ + RATE: { + rates: {btc: []}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }); + const result = store.dispatch(calculateUsdToAltFiat(100, 'EUR')); + expect(result).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// calculateAnyFiatToAltFiat +// --------------------------------------------------------------------------- +describe('calculateAnyFiatToAltFiat', () => { + it('returns the same amount when from and to currencies are identical', () => { + const store = configureTestStore(rateState); + const result = store.dispatch(calculateAnyFiatToAltFiat(200, 'USD', 'usd')); + expect(result).toBe(200); + }); + + it('converts EUR → USD', () => { + const store = configureTestStore(rateState); + // newRate = rateBtcUSD / rateBtcEUR = 50000/45000 ≈ 1.1111 + // 90 EUR * 1.1111 ≈ 100 USD + const result = store.dispatch(calculateAnyFiatToAltFiat(90, 'EUR', 'USD')); + expect(result).toBeCloseTo(100, 0); + }); + + it('converts USD → EUR', () => { + const store = configureTestStore(rateState); + // newRate = 45000/50000 = 0.9 + const result = store.dispatch(calculateAnyFiatToAltFiat(100, 'USD', 'EUR')); + expect(result).toBeCloseTo(90, 1); + }); + + it('returns undefined when source currency has no rate', () => { + const store = configureTestStore(rateState); + const result = store.dispatch(calculateAnyFiatToAltFiat(100, 'JPY', 'EUR')); + expect(result).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// getBuyCryptoFiatLimits +// --------------------------------------------------------------------------- +describe('getBuyCryptoFiatLimits', () => { + it('returns banxa limits directly for USD (a base fiat)', () => { + const store = configureTestStore(rateState); + const limits = store.dispatch(getBuyCryptoFiatLimits('banxa', 'USD')); + expect(limits).toEqual({min: 35, max: 14000}); + }); + + it('returns moonpay limits directly for EUR (a base fiat)', () => { + const store = configureTestStore(rateState); + const limits = store.dispatch(getBuyCryptoFiatLimits('moonpay', 'EUR')); + expect(limits).toEqual({min: 25, max: 10000}); + }); + + it('returns ramp limits directly for USD', () => { + const store = configureTestStore(rateState); + const limits = store.dispatch(getBuyCryptoFiatLimits('ramp', 'USD')); + expect(limits).toEqual({min: 20, max: 10000}); + }); + + it('returns sardine limits directly for USD', () => { + const store = configureTestStore(rateState); + const limits = store.dispatch(getBuyCryptoFiatLimits('sardine', 'USD')); + expect(limits).toEqual({min: 10, max: 5000}); + }); + + it('returns simplex limits directly for USD', () => { + const store = configureTestStore(rateState); + const limits = store.dispatch(getBuyCryptoFiatLimits('simplex', 'USD')); + expect(limits).toEqual({min: 50, max: 20000}); + }); + + it('returns transak limits directly for USD', () => { + const store = configureTestStore(rateState); + const limits = store.dispatch(getBuyCryptoFiatLimits('transak', 'USD')); + expect(limits).toEqual({min: 30, max: 15000}); + }); + + it('returns the global min/max when no specific exchange is given', () => { + const store = configureTestStore(rateState); + const limits = store.dispatch(getBuyCryptoFiatLimits(undefined, 'USD')); + // min: Math.min(35,25,20,10,50,30) = 10 + // max: Math.max(14000,10000,10000,5000,20000,15000) = 20000 + expect(limits.min).toBe(10); + expect(limits.max).toBe(20000); + }); + + it('converts limits from USD to EUR when fiatCurrency is EUR for sardine (USD-only exchange)', () => { + // sardine only accepts USD as a base fiat, so EUR limits should be converted + const store = configureTestStore(rateState); + const limits = store.dispatch(getBuyCryptoFiatLimits('sardine', 'EUR')); + // USD→EUR: 10*0.9=9, 5000*0.9=4500 + expect(limits.min).toBeCloseTo(9, 0); + expect(limits.max).toBeCloseTo(4500, 0); + }); + + it('returns undefined min/max when rates are missing for the alt currency', () => { + const store = configureTestStore({ + RATE: { + rates: {btc: []}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }); + const limits = store.dispatch(getBuyCryptoFiatLimits('sardine', 'JPY')); + expect(limits.min).toBeUndefined(); + expect(limits.max).toBeUndefined(); + }); +}); diff --git a/src/store/buy-crypto/buy-crypto.reducer.spec.ts b/src/store/buy-crypto/buy-crypto.reducer.spec.ts new file mode 100644 index 0000000000..ed6ea94282 --- /dev/null +++ b/src/store/buy-crypto/buy-crypto.reducer.spec.ts @@ -0,0 +1,799 @@ +/** + * Tests for buy-crypto.reducer.ts + * + * Each action handled by buyCryptoReducer is exercised as a pure function: + * buyCryptoReducer(state, action) → newState + * + * No Redux store or middleware is needed — reducers are pure functions. + */ + +import {buyCryptoReducer, BuyCryptoState} from './buy-crypto.reducer'; +import {BuyCryptoActionTypes} from './buy-crypto.types'; +import { + BanxaPaymentData, + MoonpayPaymentData, + SardinePaymentData, + SimplexPaymentData, + TransakPaymentData, + WyrePaymentData, +} from './buy-crypto.models'; +import {RampPaymentData} from './models/ramp.models'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const freshState = (): BuyCryptoState => + buyCryptoReducer(undefined, {type: '@@INIT'} as any); + +const makeBanxaData = ( + overrides: Partial = {}, +): BanxaPaymentData => ({ + address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf', + chain: 'btc', + created_on: 1700000000, + crypto_amount: 0.001, + coin: 'BTC', + env: 'prod', + fiat_base_amount: 50, + fiat_total_amount: 55, + fiat_total_amount_currency: 'USD', + order_id: 'order-123', + external_id: 'ext-banxa-1', + status: 'paymentRequestSent', + user_id: 'user-abc', + ...overrides, +}); + +const makeMoonpayData = ( + overrides: Partial = {}, +): MoonpayPaymentData => ({ + address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf', + chain: 'btc', + created_on: 1700000000, + crypto_amount: 0.001, + coin: 'BTC', + env: 'prod', + fiat_base_amount: 50, + fiat_total_amount: 55, + fiat_total_amount_currency: 'USD', + external_id: 'ext-moonpay-1', + status: 'pending', + user_id: 'user-abc', + ...overrides, +}); + +const makeRampData = ( + overrides: Partial = {}, +): RampPaymentData => ({ + address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf', + chain: 'btc', + created_on: 1700000000, + crypto_amount: 0.001, + coin: 'BTC', + env: 'prod', + fiat_base_amount: 50, + fiat_total_amount: 55, + fiat_total_amount_currency: 'USD', + external_id: 'ext-ramp-1', + status: 'paymentRequestSent', + user_id: 'user-abc', + ...overrides, +}); + +const makeSardineData = ( + overrides: Partial = {}, +): SardinePaymentData => ({ + address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf', + chain: 'btc', + created_on: 1700000000, + crypto_amount: 0.001, + coin: 'BTC', + env: 'prod', + fiat_base_amount: 50, + fiat_total_amount: 55, + fiat_total_amount_currency: 'USD', + external_id: 'ext-sardine-1', + status: 'pending', + user_id: 'user-abc', + ...overrides, +}); + +const makeSimplexData = ( + overrides: Partial = {}, +): SimplexPaymentData => ({ + address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf', + chain: 'btc', + created_on: 1700000000, + crypto_amount: 0.001, + coin: 'BTC', + env: 'prod', + fiat_base_amount: 50, + fiat_total_amount: 55, + fiat_total_amount_currency: 'USD', + order_id: 'order-simplex-1', + payment_id: 'pay-simplex-1', + status: 'paymentRequestSent', + user_id: 'user-abc', + ...overrides, +}); + +const makeTransakData = ( + overrides: Partial = {}, +): TransakPaymentData => ({ + address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf', + chain: 'btc', + created_on: 1700000000, + crypto_amount: 0.001, + coin: 'BTC', + env: 'prod', + fiat_base_amount: 50, + fiat_total_amount: 55, + fiat_total_amount_currency: 'USD', + external_id: 'ext-transak-1', + status: 'paymentRequestSent', + user_id: 'user-abc', + ...overrides, +}); + +const makeWyreData = ( + overrides: Partial = {}, +): WyrePaymentData => ({ + orderId: 'order-wyre-1', + env: 'prod', + created_on: 1700000000, + status: 'RUNNING_CHECKS', + ...overrides, +}); + +// --------------------------------------------------------------------------- +// Default state +// --------------------------------------------------------------------------- + +describe('buyCryptoReducer — default state', () => { + it('returns initialState with empty collections on unknown action', () => { + const state = freshState(); + expect(state.banxa).toEqual({}); + expect(state.moonpay).toEqual({}); + expect(state.ramp).toEqual({}); + expect(state.sardine).toEqual({}); + expect(state.simplex).toEqual({}); + expect(state.transak).toEqual({}); + expect(state.wyre).toEqual({}); + expect(state.tokens.transak).toEqual({}); + expect(state.opts.selectedPaymentMethod).toBeUndefined(); + expect(state.opts.lastPurchaseData).toBeUndefined(); + }); + + it('returns same state reference on unknown action', () => { + const state = freshState(); + const next = buyCryptoReducer(state, {type: 'UNKNOWN'} as any); + expect(next).toBe(state); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_OPTS +// --------------------------------------------------------------------------- + +describe('UPDATE_OPTS', () => { + it('merges opts into existing state', () => { + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.UPDATE_OPTS, + payload: { + buyCryptoOpts: { + selectedPaymentMethod: 'debitCard' as any, + lastPurchaseData: undefined, + }, + }, + }); + expect(state.opts.selectedPaymentMethod).toBe('debitCard'); + }); + + it('merges lastPurchaseData', () => { + const purchase = { + coin: 'BTC', + chain: 'btc', + fiatAmount: 100, + fiatCurrency: 'USD', + date: 1700000000, + partner: 'banxa', + }; + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.UPDATE_OPTS, + payload: { + buyCryptoOpts: { + selectedPaymentMethod: undefined, + lastPurchaseData: purchase, + }, + }, + }); + expect(state.opts.lastPurchaseData).toEqual(purchase); + }); +}); + +// --------------------------------------------------------------------------- +// BANXA +// --------------------------------------------------------------------------- + +describe('SUCCESS_PAYMENT_REQUEST_BANXA', () => { + it('stores the payment data keyed by external_id', () => { + const data = makeBanxaData({external_id: 'banxa-abc'}); + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.SUCCESS_PAYMENT_REQUEST_BANXA, + payload: {banxaPaymentData: data}, + }); + expect(state.banxa['banxa-abc']).toEqual(data); + }); + + it('preserves existing entries when adding a new one', () => { + const existing = makeBanxaData({external_id: 'banxa-existing'}); + const base: BuyCryptoState = { + ...freshState(), + banxa: {'banxa-existing': existing}, + }; + const newData = makeBanxaData({external_id: 'banxa-new'}); + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.SUCCESS_PAYMENT_REQUEST_BANXA, + payload: {banxaPaymentData: newData}, + }); + expect(Object.keys(state.banxa)).toHaveLength(2); + expect(state.banxa['banxa-existing']).toEqual(existing); + expect(state.banxa['banxa-new']).toEqual(newData); + }); +}); + +describe('UPDATE_PAYMENT_REQUEST_BANXA', () => { + it('updates status when entry exists', () => { + const data = makeBanxaData({ + external_id: 'banxa-1', + status: 'paymentRequestSent', + }); + const base: BuyCryptoState = {...freshState(), banxa: {'banxa-1': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_BANXA, + payload: { + banxaIncomingData: {banxaExternalId: 'banxa-1', status: 'complete'}, + }, + }); + expect(state.banxa['banxa-1'].status).toBe('complete'); + }); + + it('updates order_id via banxaOrderId', () => { + const data = makeBanxaData({external_id: 'banxa-1', order_id: 'old-order'}); + const base: BuyCryptoState = {...freshState(), banxa: {'banxa-1': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_BANXA, + payload: { + banxaIncomingData: { + banxaExternalId: 'banxa-1', + banxaOrderId: 'new-order', + }, + }, + }); + expect(state.banxa['banxa-1'].order_id).toBe('new-order'); + }); + + it('returns unchanged state when external_id not found', () => { + const base = freshState(); + const next = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_BANXA, + payload: {banxaIncomingData: {banxaExternalId: 'nonexistent'}}, + }); + expect(next).toBe(base); + }); +}); + +describe('REMOVE_PAYMENT_REQUEST_BANXA', () => { + it('removes the entry by external_id', () => { + const data = makeBanxaData({external_id: 'banxa-del'}); + const base: BuyCryptoState = {...freshState(), banxa: {'banxa-del': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.REMOVE_PAYMENT_REQUEST_BANXA, + payload: {banxaExternalId: 'banxa-del'}, + }); + expect(state.banxa['banxa-del']).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// MOONPAY +// --------------------------------------------------------------------------- + +describe('SUCCESS_PAYMENT_REQUEST_MOONPAY', () => { + it('stores the payment data keyed by external_id', () => { + const data = makeMoonpayData({external_id: 'mp-1'}); + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.SUCCESS_PAYMENT_REQUEST_MOONPAY, + payload: {moonpayPaymentData: data}, + }); + expect(state.moonpay['mp-1']).toEqual(data); + }); +}); + +describe('UPDATE_PAYMENT_REQUEST_MOONPAY', () => { + it('updates status when entry exists', () => { + const data = makeMoonpayData({external_id: 'mp-1', status: 'pending'}); + const base: BuyCryptoState = {...freshState(), moonpay: {'mp-1': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_MOONPAY, + payload: {moonpayIncomingData: {externalId: 'mp-1', status: 'completed'}}, + }); + expect(state.moonpay['mp-1'].status).toBe('completed'); + }); + + it('updates transaction_id when provided', () => { + const data = makeMoonpayData({external_id: 'mp-1'}); + const base: BuyCryptoState = {...freshState(), moonpay: {'mp-1': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_MOONPAY, + payload: { + moonpayIncomingData: {externalId: 'mp-1', transactionId: 'tx-mp-1'}, + }, + }); + expect(state.moonpay['mp-1'].transaction_id).toBe('tx-mp-1'); + }); + + it('returns unchanged state when entry not found', () => { + const base = freshState(); + const next = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_MOONPAY, + payload: {moonpayIncomingData: {externalId: 'ghost'}}, + }); + expect(next).toBe(base); + }); +}); + +describe('REMOVE_PAYMENT_REQUEST_MOONPAY', () => { + it('removes the entry', () => { + const data = makeMoonpayData({external_id: 'mp-del'}); + const base: BuyCryptoState = {...freshState(), moonpay: {'mp-del': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.REMOVE_PAYMENT_REQUEST_MOONPAY, + payload: {externalId: 'mp-del'}, + }); + expect(state.moonpay['mp-del']).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// RAMP +// --------------------------------------------------------------------------- + +describe('SUCCESS_PAYMENT_REQUEST_RAMP', () => { + it('stores the payment data keyed by external_id', () => { + const data = makeRampData({external_id: 'ramp-1'}); + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.SUCCESS_PAYMENT_REQUEST_RAMP, + payload: {rampPaymentData: data}, + }); + expect(state.ramp['ramp-1']).toEqual(data); + }); +}); + +describe('UPDATE_PAYMENT_REQUEST_RAMP', () => { + it('updates status when entry exists', () => { + const data = makeRampData({ + external_id: 'ramp-1', + status: 'paymentRequestSent', + }); + const base: BuyCryptoState = {...freshState(), ramp: {'ramp-1': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_RAMP, + payload: { + rampIncomingData: {rampExternalId: 'ramp-1', status: 'complete'}, + }, + }); + expect(state.ramp['ramp-1'].status).toBe('complete'); + }); + + it('returns unchanged state when entry not found', () => { + const base = freshState(); + const next = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_RAMP, + payload: {rampIncomingData: {rampExternalId: 'ghost'}}, + }); + expect(next).toBe(base); + }); +}); + +describe('REMOVE_PAYMENT_REQUEST_RAMP', () => { + it('removes the entry', () => { + const data = makeRampData({external_id: 'ramp-del'}); + const base: BuyCryptoState = {...freshState(), ramp: {'ramp-del': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.REMOVE_PAYMENT_REQUEST_RAMP, + payload: {rampExternalId: 'ramp-del'}, + }); + expect(state.ramp['ramp-del']).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// SARDINE +// --------------------------------------------------------------------------- + +describe('SUCCESS_PAYMENT_REQUEST_SARDINE', () => { + it('stores the payment data keyed by external_id', () => { + const data = makeSardineData({external_id: 'sardine-1'}); + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.SUCCESS_PAYMENT_REQUEST_SARDINE, + payload: {sardinePaymentData: data}, + }); + expect(state.sardine['sardine-1']).toEqual(data); + }); +}); + +describe('UPDATE_PAYMENT_REQUEST_SARDINE', () => { + it('updates status when entry exists', () => { + const data = makeSardineData({external_id: 'sardine-1', status: 'pending'}); + const base: BuyCryptoState = { + ...freshState(), + sardine: {'sardine-1': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_SARDINE, + payload: { + sardineIncomingData: { + sardineExternalId: 'sardine-1', + status: 'complete', + }, + }, + }); + expect(state.sardine['sardine-1'].status).toBe('complete'); + }); + + it('updates order_id when provided', () => { + const data = makeSardineData({external_id: 'sardine-1'}); + const base: BuyCryptoState = { + ...freshState(), + sardine: {'sardine-1': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_SARDINE, + payload: { + sardineIncomingData: { + sardineExternalId: 'sardine-1', + order_id: 'oid-1', + }, + }, + }); + expect(state.sardine['sardine-1'].order_id).toBe('oid-1'); + }); + + it('updates crypto_amount when provided', () => { + const data = makeSardineData({ + external_id: 'sardine-1', + crypto_amount: 0.001, + }); + const base: BuyCryptoState = { + ...freshState(), + sardine: {'sardine-1': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_SARDINE, + payload: { + sardineIncomingData: { + sardineExternalId: 'sardine-1', + cryptoAmount: 0.005, + }, + }, + }); + expect(state.sardine['sardine-1'].crypto_amount).toBe(0.005); + }); + + it('updates transaction_id when provided', () => { + const data = makeSardineData({external_id: 'sardine-1'}); + const base: BuyCryptoState = { + ...freshState(), + sardine: {'sardine-1': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_SARDINE, + payload: { + sardineIncomingData: { + sardineExternalId: 'sardine-1', + transactionId: 'tx-s-1', + }, + }, + }); + expect(state.sardine['sardine-1'].transaction_id).toBe('tx-s-1'); + }); + + it('returns unchanged state when entry not found', () => { + const base = freshState(); + const next = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_SARDINE, + payload: {sardineIncomingData: {sardineExternalId: 'ghost'}}, + }); + expect(next).toBe(base); + }); +}); + +describe('REMOVE_PAYMENT_REQUEST_SARDINE', () => { + it('removes the entry', () => { + const data = makeSardineData({external_id: 'sardine-del'}); + const base: BuyCryptoState = { + ...freshState(), + sardine: {'sardine-del': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.REMOVE_PAYMENT_REQUEST_SARDINE, + payload: {sardineExternalId: 'sardine-del'}, + }); + expect(state.sardine['sardine-del']).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// SIMPLEX +// --------------------------------------------------------------------------- + +describe('SUCCESS_PAYMENT_REQUEST_SIMPLEX', () => { + it('stores the payment data keyed by payment_id', () => { + const data = makeSimplexData({payment_id: 'pay-1'}); + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.SUCCESS_PAYMENT_REQUEST_SIMPLEX, + payload: {simplexPaymentData: data}, + }); + expect(state.simplex['pay-1']).toEqual(data); + }); +}); + +describe('UPDATE_PAYMENT_REQUEST_SIMPLEX', () => { + it('sets status to success when success==="true"', () => { + const data = makeSimplexData({ + payment_id: 'pay-1', + status: 'paymentRequestSent', + }); + const base: BuyCryptoState = {...freshState(), simplex: {'pay-1': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_SIMPLEX, + payload: {simplexIncomingData: {paymentId: 'pay-1', success: 'true'}}, + }); + expect(state.simplex['pay-1'].status).toBe('success'); + }); + + it('sets status to failed when success!=="true"', () => { + const data = makeSimplexData({ + payment_id: 'pay-1', + status: 'paymentRequestSent', + }); + const base: BuyCryptoState = {...freshState(), simplex: {'pay-1': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_SIMPLEX, + payload: {simplexIncomingData: {paymentId: 'pay-1', success: 'false'}}, + }); + expect(state.simplex['pay-1'].status).toBe('failed'); + }); + + it('returns unchanged state when entry not found', () => { + const base = freshState(); + const next = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_SIMPLEX, + payload: {simplexIncomingData: {paymentId: 'ghost'}}, + }); + expect(next).toBe(base); + }); +}); + +describe('REMOVE_PAYMENT_REQUEST_SIMPLEX', () => { + it('removes the entry', () => { + const data = makeSimplexData({payment_id: 'pay-del'}); + const base: BuyCryptoState = {...freshState(), simplex: {'pay-del': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.REMOVE_PAYMENT_REQUEST_SIMPLEX, + payload: {paymentId: 'pay-del'}, + }); + expect(state.simplex['pay-del']).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// TRANSAK +// --------------------------------------------------------------------------- + +describe('SUCCESS_PAYMENT_REQUEST_TRANSAK', () => { + it('stores the payment data keyed by external_id', () => { + const data = makeTransakData({external_id: 'transak-1'}); + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.SUCCESS_PAYMENT_REQUEST_TRANSAK, + payload: {transakPaymentData: data}, + }); + expect(state.transak['transak-1']).toEqual(data); + }); +}); + +describe('UPDATE_PAYMENT_REQUEST_TRANSAK', () => { + it('updates status when entry exists', () => { + const data = makeTransakData({ + external_id: 'transak-1', + status: 'paymentRequestSent', + }); + const base: BuyCryptoState = { + ...freshState(), + transak: {'transak-1': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_TRANSAK, + payload: { + transakIncomingData: { + transakExternalId: 'transak-1', + status: 'COMPLETED', + }, + }, + }); + expect(state.transak['transak-1'].status).toBe('COMPLETED'); + }); + + it('updates order_id when provided', () => { + const data = makeTransakData({external_id: 'transak-1'}); + const base: BuyCryptoState = { + ...freshState(), + transak: {'transak-1': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_TRANSAK, + payload: { + transakIncomingData: { + transakExternalId: 'transak-1', + order_id: 'oid-t-1', + }, + }, + }); + expect(state.transak['transak-1'].order_id).toBe('oid-t-1'); + }); + + it('updates crypto_amount when provided', () => { + const data = makeTransakData({ + external_id: 'transak-1', + crypto_amount: 0.001, + }); + const base: BuyCryptoState = { + ...freshState(), + transak: {'transak-1': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_TRANSAK, + payload: { + transakIncomingData: { + transakExternalId: 'transak-1', + cryptoAmount: 0.01, + }, + }, + }); + expect(state.transak['transak-1'].crypto_amount).toBe(0.01); + }); + + it('updates transaction_id when provided', () => { + const data = makeTransakData({external_id: 'transak-1'}); + const base: BuyCryptoState = { + ...freshState(), + transak: {'transak-1': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_TRANSAK, + payload: { + transakIncomingData: { + transakExternalId: 'transak-1', + transactionId: 'tx-t-1', + }, + }, + }); + expect(state.transak['transak-1'].transaction_id).toBe('tx-t-1'); + }); + + it('returns unchanged state when entry not found', () => { + const base = freshState(); + const next = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.UPDATE_PAYMENT_REQUEST_TRANSAK, + payload: {transakIncomingData: {transakExternalId: 'ghost'}}, + }); + expect(next).toBe(base); + }); +}); + +describe('REMOVE_PAYMENT_REQUEST_TRANSAK', () => { + it('removes the entry', () => { + const data = makeTransakData({external_id: 'transak-del'}); + const base: BuyCryptoState = { + ...freshState(), + transak: {'transak-del': data}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.REMOVE_PAYMENT_REQUEST_TRANSAK, + payload: {transakExternalId: 'transak-del'}, + }); + expect(state.transak['transak-del']).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// ACCESS_TOKEN_TRANSAK +// --------------------------------------------------------------------------- + +describe('ACCESS_TOKEN_TRANSAK', () => { + it('stores an access token for the sandbox env', () => { + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.ACCESS_TOKEN_TRANSAK, + payload: {env: 'sandbox', accessToken: 'tok-sandbox', expiresAt: 9999999}, + }); + expect(state.tokens.transak.sandbox?.accessToken).toBe('tok-sandbox'); + expect(state.tokens.transak.sandbox?.expiresAt).toBe(9999999); + }); + + it('stores an access token for the production env', () => { + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.ACCESS_TOKEN_TRANSAK, + payload: {env: 'production', accessToken: 'tok-prod', expiresAt: 8888888}, + }); + expect(state.tokens.transak.production?.accessToken).toBe('tok-prod'); + expect(state.tokens.transak.production?.expiresAt).toBe(8888888); + }); + + it('does not affect the other env when updating one', () => { + let state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.ACCESS_TOKEN_TRANSAK, + payload: {env: 'sandbox', accessToken: 'tok-sandbox', expiresAt: 9999999}, + }); + state = buyCryptoReducer(state, { + type: BuyCryptoActionTypes.ACCESS_TOKEN_TRANSAK, + payload: {env: 'production', accessToken: 'tok-prod', expiresAt: 7777777}, + }); + expect(state.tokens.transak.sandbox?.accessToken).toBe('tok-sandbox'); + expect(state.tokens.transak.production?.accessToken).toBe('tok-prod'); + }); +}); + +// --------------------------------------------------------------------------- +// WYRE +// --------------------------------------------------------------------------- + +describe('SUCCESS_PAYMENT_REQUEST_WYRE', () => { + it('stores the payment data keyed by orderId', () => { + const data = makeWyreData({orderId: 'wyre-1'}); + const state = buyCryptoReducer(freshState(), { + type: BuyCryptoActionTypes.SUCCESS_PAYMENT_REQUEST_WYRE, + payload: {wyrePaymentData: data}, + }); + expect(state.wyre['wyre-1']).toBeDefined(); + expect(state.wyre['wyre-1'].orderId).toBe('wyre-1'); + }); + + it('returns unchanged state when orderId is missing', () => { + const base = freshState(); + const data: WyrePaymentData = {env: 'prod', created_on: 1700000000} as any; + const next = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.SUCCESS_PAYMENT_REQUEST_WYRE, + payload: {wyrePaymentData: data}, + }); + expect(next).toBe(base); + }); +}); + +describe('REMOVE_PAYMENT_REQUEST_WYRE', () => { + it('removes the entry by orderId', () => { + const data = makeWyreData({orderId: 'wyre-del'}); + const base: BuyCryptoState = {...freshState(), wyre: {'wyre-del': data}}; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.REMOVE_PAYMENT_REQUEST_WYRE, + payload: {orderId: 'wyre-del'}, + }); + expect(state.wyre['wyre-del']).toBeUndefined(); + }); + + it('does not affect other wyre entries', () => { + const keep = makeWyreData({orderId: 'wyre-keep'}); + const del = makeWyreData({orderId: 'wyre-del'}); + const base: BuyCryptoState = { + ...freshState(), + wyre: {'wyre-keep': keep, 'wyre-del': del}, + }; + const state = buyCryptoReducer(base, { + type: BuyCryptoActionTypes.REMOVE_PAYMENT_REQUEST_WYRE, + payload: {orderId: 'wyre-del'}, + }); + expect(state.wyre['wyre-keep']).toEqual(keep); + expect(state.wyre['wyre-del']).toBeUndefined(); + }); +}); diff --git a/src/store/card/card.reducer.spec.ts b/src/store/card/card.reducer.spec.ts new file mode 100644 index 0000000000..abd534724b --- /dev/null +++ b/src/store/card/card.reducer.spec.ts @@ -0,0 +1,698 @@ +/** + * Tests for card.reducer.ts + * + * Each action handled by cardReducer is exercised as a pure function: + * cardReducer(state, action) → newState + * + * No Redux store or middleware is needed — reducers are pure functions. + */ + +import {cardReducer, CardState} from './card.reducer'; +import {CardActionTypes} from './card.types'; +import {BitPayIdActionTypes} from '../bitpay-id/bitpay-id.types'; +import {Network} from '../../constants'; +import {Card} from './card.models'; +import {CardBrand, CardProvider} from '../../constants/card'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const freshState = (): CardState => + cardReducer(undefined, {type: '@@INIT'} as any); + +const makeCard = (overrides: Partial = {}): Card => ({ + activationDate: '2024-01-01', + brand: CardBrand.Visa, + cardType: 'virtual', + currency: { + code: 'USD', + decimals: 2, + name: 'US Dollar', + precision: 2, + symbol: '$', + }, + disabled: false, + id: 'card-1', + lastFourDigits: '1234', + lockedByUser: false, + nickname: 'My Card', + pagingSupport: true, + provider: CardProvider.galileo, + status: 'active', + token: 'tok-1', + ...overrides, +}); + +const makePagedTxData = (overrides: Partial = {}) => ({ + totalPageCount: 1, + currentPageNumber: 1, + totalRecordCount: 1, + transactionList: [{id: 'tx-1', amount: 10} as any], + ...overrides, +}); + +// --------------------------------------------------------------------------- +// Default state +// --------------------------------------------------------------------------- + +describe('cardReducer — default state', () => { + it('returns expected defaults on unknown action', () => { + const state = freshState(); + expect(state.fetchCardsStatus).toBeNull(); + expect(state.fetchVirtualCardImageUrlsStatus).toBeNull(); + expect(state.cards[Network.mainnet]).toEqual([]); + expect(state.cards[Network.testnet]).toEqual([]); + expect(state.balances).toEqual({}); + expect(state.virtualCardImages).toEqual({}); + expect(state.virtualDesignCurrency).toBe('bitpay-b'); + expect(state.overview).toBeNull(); + expect(state.activateCardStatus).toBeNull(); + expect(state.activateCardError).toBeNull(); + expect(state.isJoinedWaitlist).toBe(false); + }); + + it('returns same state reference on unknown action', () => { + const state = freshState(); + const next = cardReducer(state, {type: 'UNKNOWN'} as any); + expect(next).toBe(state); + }); +}); + +// --------------------------------------------------------------------------- +// BITPAY_ID_DISCONNECTED +// --------------------------------------------------------------------------- + +describe('BITPAY_ID_DISCONNECTED', () => { + it('clears cards for the specified network and resets balances', () => { + const card = makeCard({id: 'card-1'}); + const base: CardState = { + ...freshState(), + cards: { + [Network.mainnet]: [card], + [Network.testnet]: [], + [Network.regtest]: [], + }, + balances: {'card-1': 100}, + }; + const state = cardReducer(base, { + type: BitPayIdActionTypes.BITPAY_ID_DISCONNECTED, + payload: {network: Network.mainnet}, + }); + expect(state.cards[Network.mainnet]).toEqual([]); + expect(state.balances).toEqual({}); + }); + + it('does not clear cards for other networks', () => { + const card = makeCard({id: 'card-testnet'}); + const base: CardState = { + ...freshState(), + cards: { + [Network.mainnet]: [], + [Network.testnet]: [card], + [Network.regtest]: [], + }, + }; + const state = cardReducer(base, { + type: BitPayIdActionTypes.BITPAY_ID_DISCONNECTED, + payload: {network: Network.mainnet}, + }); + expect(state.cards[Network.testnet]).toEqual([card]); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_FETCH_CARDS +// --------------------------------------------------------------------------- + +describe('SUCCESS_FETCH_CARDS', () => { + it('sets fetchCardsStatus to success and stores cards for the network', () => { + const card = makeCard({ + id: 'card-fetched', + provider: CardProvider.galileo, + status: 'active', + disabled: false, + }); + const state = cardReducer(freshState(), { + type: CardActionTypes.SUCCESS_FETCH_CARDS, + payload: {network: Network.mainnet, cards: [card]}, + }); + expect(state.fetchCardsStatus).toBe('success'); + expect(state.cards[Network.mainnet]).toHaveLength(1); + expect(state.cards[Network.mainnet][0].id).toBe('card-fetched'); + }); + + it('handles empty cards array', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.SUCCESS_FETCH_CARDS, + payload: {network: Network.mainnet, cards: []}, + }); + expect(state.fetchCardsStatus).toBe('success'); + expect(state.cards[Network.mainnet]).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// FAILED_FETCH_CARDS +// --------------------------------------------------------------------------- + +describe('FAILED_FETCH_CARDS', () => { + it('sets fetchCardsStatus to failed', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.FAILED_FETCH_CARDS, + }); + expect(state.fetchCardsStatus).toBe('failed'); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_FETCH_CARDS_STATUS +// --------------------------------------------------------------------------- + +describe('UPDATE_FETCH_CARDS_STATUS', () => { + it('sets fetchCardsStatus to the given value', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_FETCH_CARDS_STATUS, + payload: 'success', + }); + expect(state.fetchCardsStatus).toBe('success'); + }); + + it('can reset fetchCardsStatus to null', () => { + const base: CardState = {...freshState(), fetchCardsStatus: 'failed'}; + const state = cardReducer(base, { + type: CardActionTypes.UPDATE_FETCH_CARDS_STATUS, + payload: null, + }); + expect(state.fetchCardsStatus).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// VIRTUAL_DESIGN_CURRENCY_UPDATED +// --------------------------------------------------------------------------- + +describe('VIRTUAL_DESIGN_CURRENCY_UPDATED', () => { + it('updates virtualDesignCurrency', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.VIRTUAL_DESIGN_CURRENCY_UPDATED, + payload: 'BTC', + }); + expect(state.virtualDesignCurrency).toBe('BTC'); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_FETCH_OVERVIEW +// --------------------------------------------------------------------------- + +describe('SUCCESS_FETCH_OVERVIEW', () => { + it('sets status to success and updates balance, settled/pending transactions', () => { + const before = Date.now(); + const state = cardReducer(freshState(), { + type: CardActionTypes.SUCCESS_FETCH_OVERVIEW, + payload: { + id: 'card-1', + balance: 500, + settledTransactions: makePagedTxData(), + pendingTransactions: [{id: 'ptx-1'} as any], + topUpHistory: [{id: 'tup-1'} as any], + }, + }); + expect(state.fetchOverviewStatus['card-1']).toBe('success'); + expect(state.balances['card-1']).toBe(500); + expect(state.settledTransactions['card-1']).toBeDefined(); + expect(state.pendingTransactions['card-1']).toHaveLength(1); + expect(state.topUpHistory['card-1']).toHaveLength(1); + expect(state.lastUpdates.fetchOverview).toBeGreaterThanOrEqual(before); + }); +}); + +// --------------------------------------------------------------------------- +// FAILED_FETCH_OVERVIEW / UPDATE_FETCH_OVERVIEW_STATUS +// --------------------------------------------------------------------------- + +describe('FAILED_FETCH_OVERVIEW', () => { + it('sets fetchOverviewStatus to failed for the given id', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.FAILED_FETCH_OVERVIEW, + payload: {id: 'card-1'}, + }); + expect(state.fetchOverviewStatus['card-1']).toBe('failed'); + }); +}); + +describe('UPDATE_FETCH_OVERVIEW_STATUS', () => { + it('sets the overview status for the given id', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_FETCH_OVERVIEW_STATUS, + payload: {id: 'card-1', status: 'loading'}, + }); + expect(state.fetchOverviewStatus['card-1']).toBe('loading'); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_FETCH_SETTLED_TRANSACTIONS +// --------------------------------------------------------------------------- + +describe('SUCCESS_FETCH_SETTLED_TRANSACTIONS', () => { + it('stores transactions for a new card', () => { + const txData = makePagedTxData({currentPageNumber: 1, totalPageCount: 2}); + const state = cardReducer(freshState(), { + type: CardActionTypes.SUCCESS_FETCH_SETTLED_TRANSACTIONS, + payload: {id: 'card-1', transactions: txData}, + }); + expect(state.fetchSettledTransactionsStatus['card-1']).toBe('success'); + expect(state.settledTransactions['card-1']).toEqual(txData); + }); + + it('appends transactions when new page number is greater than current', () => { + const existingTx = makePagedTxData({ + currentPageNumber: 1, + transactionList: [{id: 'tx-old'} as any], + }); + const base: CardState = { + ...freshState(), + settledTransactions: {'card-1': existingTx}, + }; + const newTx = makePagedTxData({ + currentPageNumber: 2, + transactionList: [{id: 'tx-new'} as any], + }); + const state = cardReducer(base, { + type: CardActionTypes.SUCCESS_FETCH_SETTLED_TRANSACTIONS, + payload: {id: 'card-1', transactions: newTx}, + }); + // should have both old and new transactions + expect(state.settledTransactions['card-1']?.transactionList).toHaveLength( + 2, + ); + }); + + it('replaces transactions when page number is not greater', () => { + const existingTx = makePagedTxData({ + currentPageNumber: 2, + transactionList: [{id: 'tx-old'} as any], + }); + const base: CardState = { + ...freshState(), + settledTransactions: {'card-1': existingTx}, + }; + const newTx = makePagedTxData({ + currentPageNumber: 1, + transactionList: [{id: 'tx-replace'} as any], + }); + const state = cardReducer(base, { + type: CardActionTypes.SUCCESS_FETCH_SETTLED_TRANSACTIONS, + payload: {id: 'card-1', transactions: newTx}, + }); + expect(state.settledTransactions['card-1']?.transactionList).toHaveLength( + 1, + ); + expect(state.settledTransactions['card-1']?.transactionList[0].id).toBe( + 'tx-replace', + ); + }); +}); + +// --------------------------------------------------------------------------- +// FAILED_FETCH_SETTLED_TRANSACTIONS / UPDATE_FETCH_SETTLED_TRANSACTIONS_STATUS +// --------------------------------------------------------------------------- + +describe('FAILED_FETCH_SETTLED_TRANSACTIONS', () => { + it('sets status to failed for the given card', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.FAILED_FETCH_SETTLED_TRANSACTIONS, + payload: {id: 'card-1'}, + }); + expect(state.fetchSettledTransactionsStatus['card-1']).toBe('failed'); + }); +}); + +describe('UPDATE_FETCH_SETTLED_TRANSACTIONS_STATUS', () => { + it('sets the status for the given card', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_FETCH_SETTLED_TRANSACTIONS_STATUS, + payload: {id: 'card-1', status: 'success'}, + }); + expect(state.fetchSettledTransactionsStatus['card-1']).toBe('success'); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_FETCH_VIRTUAL_IMAGE_URLS +// --------------------------------------------------------------------------- + +describe('SUCCESS_FETCH_VIRTUAL_IMAGE_URLS', () => { + it('sets fetchVirtualCardImageUrlsStatus to success and stores images', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.SUCCESS_FETCH_VIRTUAL_IMAGE_URLS, + payload: [ + {id: 'card-1', virtualCardImage: 'https://img.example.com/card-1.png'}, + ], + }); + expect(state.fetchVirtualCardImageUrlsStatus).toBe('success'); + expect(state.virtualCardImages['card-1']).toBe( + 'https://img.example.com/card-1.png', + ); + }); + + it('merges multiple images', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.SUCCESS_FETCH_VIRTUAL_IMAGE_URLS, + payload: [ + {id: 'card-1', virtualCardImage: 'https://img.example.com/1.png'}, + {id: 'card-2', virtualCardImage: 'https://img.example.com/2.png'}, + ], + }); + expect(Object.keys(state.virtualCardImages)).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// FAILED_FETCH_VIRTUAL_IMAGE_URLS / UPDATE_FETCH_VIRTUAL_IMAGE_URLS_STATUS +// --------------------------------------------------------------------------- + +describe('FAILED_FETCH_VIRTUAL_IMAGE_URLS', () => { + it('sets fetchVirtualCardImageUrlsStatus to failed', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.FAILED_FETCH_VIRTUAL_IMAGE_URLS, + }); + expect(state.fetchVirtualCardImageUrlsStatus).toBe('failed'); + }); +}); + +describe('UPDATE_FETCH_VIRTUAL_IMAGE_URLS_STATUS', () => { + it('sets the status', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_FETCH_VIRTUAL_IMAGE_URLS_STATUS, + payload: 'success', + }); + expect(state.fetchVirtualCardImageUrlsStatus).toBe('success'); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_UPDATE_CARD_LOCK +// --------------------------------------------------------------------------- + +describe('SUCCESS_UPDATE_CARD_LOCK', () => { + it('sets updateCardLockStatus to success and updates lockedByUser on the card', () => { + const card = makeCard({id: 'card-1', lockedByUser: false}); + const base: CardState = { + ...freshState(), + cards: { + [Network.mainnet]: [card], + [Network.testnet]: [], + [Network.regtest]: [], + }, + }; + const state = cardReducer(base, { + type: CardActionTypes.SUCCESS_UPDATE_CARD_LOCK, + payload: {network: Network.mainnet, id: 'card-1', locked: true}, + }); + expect(state.updateCardLockStatus['card-1']).toBe('success'); + expect(state.cards[Network.mainnet][0].lockedByUser).toBe(true); + }); + + it('does not modify cards that do not match the id', () => { + const card1 = makeCard({id: 'card-1', lockedByUser: false}); + const card2 = makeCard({id: 'card-2', lockedByUser: false}); + const base: CardState = { + ...freshState(), + cards: { + [Network.mainnet]: [card1, card2], + [Network.testnet]: [], + [Network.regtest]: [], + }, + }; + const state = cardReducer(base, { + type: CardActionTypes.SUCCESS_UPDATE_CARD_LOCK, + payload: {network: Network.mainnet, id: 'card-1', locked: true}, + }); + expect(state.cards[Network.mainnet][1].lockedByUser).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// FAILED_UPDATE_CARD_LOCK / UPDATE_UPDATE_CARD_LOCK_STATUS +// --------------------------------------------------------------------------- + +describe('FAILED_UPDATE_CARD_LOCK', () => { + it('sets updateCardLockStatus to failed', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.FAILED_UPDATE_CARD_LOCK, + payload: {id: 'card-1'}, + }); + expect(state.updateCardLockStatus['card-1']).toBe('failed'); + }); +}); + +describe('UPDATE_UPDATE_CARD_LOCK_STATUS', () => { + it('sets the lock status for the given card id', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_UPDATE_CARD_LOCK_STATUS, + payload: {id: 'card-1', status: null}, + }); + expect(state.updateCardLockStatus['card-1']).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_UPDATE_CARD_NAME +// --------------------------------------------------------------------------- + +describe('SUCCESS_UPDATE_CARD_NAME', () => { + it('sets updateCardNameStatus to success and updates nickname on the card', () => { + const card = makeCard({id: 'card-1', nickname: 'Old Name'}); + const base: CardState = { + ...freshState(), + cards: { + [Network.mainnet]: [card], + [Network.testnet]: [], + [Network.regtest]: [], + }, + }; + const state = cardReducer(base, { + type: CardActionTypes.SUCCESS_UPDATE_CARD_NAME, + payload: {network: Network.mainnet, id: 'card-1', nickname: 'New Name'}, + }); + expect(state.updateCardNameStatus['card-1']).toBe('success'); + expect(state.cards[Network.mainnet][0].nickname).toBe('New Name'); + }); +}); + +// --------------------------------------------------------------------------- +// FAILED_UPDATE_CARD_NAME / UPDATE_UPDATE_CARD_NAME_STATUS +// --------------------------------------------------------------------------- + +describe('FAILED_UPDATE_CARD_NAME', () => { + it('sets updateCardNameStatus to failed', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.FAILED_UPDATE_CARD_NAME, + payload: {id: 'card-1'}, + }); + expect(state.updateCardNameStatus['card-1']).toBe('failed'); + }); +}); + +describe('UPDATE_UPDATE_CARD_NAME_STATUS', () => { + it('sets updateCardNameStatus for the given card', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_UPDATE_CARD_NAME_STATUS, + payload: {id: 'card-1', status: 'success'}, + }); + expect(state.updateCardNameStatus['card-1']).toBe('success'); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_FETCH_REFERRAL_CODE / UPDATE_FETCH_REFERRAL_CODE_STATUS +// --------------------------------------------------------------------------- + +describe('referral code actions', () => { + it('SUCCESS_FETCH_REFERRAL_CODE stores the code', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.SUCCESS_FETCH_REFERRAL_CODE, + payload: {id: 'card-1', code: 'REF123'}, + }); + expect(state.referralCode['card-1']).toBe('REF123'); + }); + + it('UPDATE_FETCH_REFERRAL_CODE_STATUS stores the status', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_FETCH_REFERRAL_CODE_STATUS, + payload: {id: 'card-1', status: 'loading' as any}, + }); + expect(state.referralCode['card-1']).toBe('loading'); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_FETCH_REFERRED_USERS / UPDATE_FETCH_REFERRED_USERS_STATUS +// --------------------------------------------------------------------------- + +describe('referred users actions', () => { + it('SUCCESS_FETCH_REFERRED_USERS stores the users list', () => { + const users = [{userId: 'u-1'} as any]; + const state = cardReducer(freshState(), { + type: CardActionTypes.SUCCESS_FETCH_REFERRED_USERS, + payload: {id: 'card-1', referredUsers: users}, + }); + expect(state.referredUsers['card-1']).toEqual(users); + }); + + it('UPDATE_FETCH_REFERRED_USERS_STATUS stores the status', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_FETCH_REFERRED_USERS_STATUS, + payload: {id: 'card-1', status: 'loading'}, + }); + expect(state.referredUsers['card-1']).toBe('loading'); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_ACTIVATE_CARD / FAILED_ACTIVATE_CARD / UPDATE_ACTIVATE_CARD_STATUS +// --------------------------------------------------------------------------- + +describe('activate card actions', () => { + it('SUCCESS_ACTIVATE_CARD sets status to success and clears error', () => { + const base: CardState = {...freshState(), activateCardError: 'old error'}; + const state = cardReducer(base, { + type: CardActionTypes.SUCCESS_ACTIVATE_CARD, + payload: undefined, + }); + expect(state.activateCardStatus).toBe('success'); + expect(state.activateCardError).toBeNull(); + }); + + it('FAILED_ACTIVATE_CARD sets status to failed and stores error', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.FAILED_ACTIVATE_CARD, + payload: 'Card not found', + }); + expect(state.activateCardStatus).toBe('failed'); + expect(state.activateCardError).toBe('Card not found'); + }); + + it('FAILED_ACTIVATE_CARD with undefined payload stores null error', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.FAILED_ACTIVATE_CARD, + payload: undefined, + }); + expect(state.activateCardError).toBeNull(); + }); + + it('UPDATE_ACTIVATE_CARD_STATUS sets the status', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_ACTIVATE_CARD_STATUS, + payload: null, + }); + expect(state.activateCardStatus).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// PIN CHANGE REQUEST actions +// --------------------------------------------------------------------------- + +describe('pin change request actions', () => { + it('SUCCESS_FETCH_PIN_CHANGE_REQUEST_INFO stores info and sets status to success', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.SUCCESS_FETCH_PIN_CHANGE_REQUEST_INFO, + payload: {id: 'card-1', pinChangeRequestInfo: 'token-abc'}, + }); + expect(state.pinChangeRequestInfoStatus['card-1']).toBe('success'); + expect(state.pinChangeRequestInfoError['card-1']).toBeNull(); + expect(state.pinChangeRequestInfo['card-1']).toBe('token-abc'); + }); + + it('FAILED_FETCH_PIN_CHANGE_REQUEST_INFO sets status to failed and stores error', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.FAILED_FETCH_PIN_CHANGE_REQUEST_INFO, + payload: {id: 'card-1', error: 'Network error'}, + }); + expect(state.pinChangeRequestInfoStatus['card-1']).toBe('failed'); + expect(state.pinChangeRequestInfoError['card-1']).toBe('Network error'); + }); + + it('UPDATE_FETCH_PIN_CHANGE_REQUEST_INFO_STATUS sets the status', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.UPDATE_FETCH_PIN_CHANGE_REQUEST_INFO_STATUS, + payload: {id: 'card-1', status: null}, + }); + expect(state.pinChangeRequestInfoStatus['card-1']).toBeNull(); + }); + + it('RESET_PIN_CHANGE_REQUEST_INFO clears info, status, and error', () => { + const base: CardState = { + ...freshState(), + pinChangeRequestInfo: {'card-1': 'token-abc'}, + pinChangeRequestInfoStatus: {'card-1': 'success'}, + pinChangeRequestInfoError: {'card-1': null}, + }; + const state = cardReducer(base, { + type: CardActionTypes.RESET_PIN_CHANGE_REQUEST_INFO, + payload: {id: 'card-1'}, + }); + expect(state.pinChangeRequestInfo['card-1']).toBeNull(); + expect(state.pinChangeRequestInfoStatus['card-1']).toBeNull(); + expect(state.pinChangeRequestInfoError['card-1']).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// CONFIRM PIN CHANGE actions +// --------------------------------------------------------------------------- + +describe('confirm pin change actions', () => { + it('CONFIRM_PIN_CHANGE_SUCCESS sets status to success and clears error', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.CONFIRM_PIN_CHANGE_SUCCESS, + payload: {id: 'card-1'}, + }); + expect(state.confirmPinChangeStatus['card-1']).toBe('success'); + expect(state.confirmPinChangeError['card-1']).toBeNull(); + }); + + it('CONFIRM_PIN_CHANGE_FAILED sets status to failed and stores error', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.CONFIRM_PIN_CHANGE_FAILED, + payload: {id: 'card-1', error: 'PIN mismatch'}, + }); + expect(state.confirmPinChangeStatus['card-1']).toBe('failed'); + expect(state.confirmPinChangeError['card-1']).toBe('PIN mismatch'); + }); + + it('CONFIRM_PIN_CHANGE_STATUS_UPDATED sets the status', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.CONFIRM_PIN_CHANGE_STATUS_UPDATED, + payload: {id: 'card-1', status: null}, + }); + expect(state.confirmPinChangeStatus['card-1']).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// IS_JOINED_WAITLIST +// --------------------------------------------------------------------------- + +describe('IS_JOINED_WAITLIST', () => { + it('sets isJoinedWaitlist to true', () => { + const state = cardReducer(freshState(), { + type: CardActionTypes.IS_JOINED_WAITLIST, + payload: {isJoinedWaitlist: true}, + }); + expect(state.isJoinedWaitlist).toBe(true); + }); + + it('sets isJoinedWaitlist to false', () => { + const base: CardState = {...freshState(), isJoinedWaitlist: true}; + const state = cardReducer(base, { + type: CardActionTypes.IS_JOINED_WAITLIST, + payload: {isJoinedWaitlist: false}, + }); + expect(state.isJoinedWaitlist).toBe(false); + }); +}); diff --git a/src/store/coinbase/coinbase.effects.spec.ts b/src/store/coinbase/coinbase.effects.spec.ts new file mode 100644 index 0000000000..1fd1eacf36 --- /dev/null +++ b/src/store/coinbase/coinbase.effects.spec.ts @@ -0,0 +1,713 @@ +/** + * Tests for coinbase.effects.ts + * + * Covers: + * - isInvalidTokenError + * - coinbaseParseErrorToString + * - coinbaseErrorIncludesErrorParams + * - coinbaseGetFiatAmount + * - coinbaseDisconnectAccount + * - coinbaseRefreshToken (no-token early return) + * - coinbaseGetUser (success, expired token, revoked token, invalid token, generic error) + * - coinbaseGetAccountsAndBalance (success, no-token early return) + * - coinbaseGetTransactionsByAccount (no-token / cache-hit early returns) + * - coinbaseCreateAddress (success, no-token early return) + * - coinbaseSendTransaction (no-token early return) + * - coinbaseClearSendTransactionStatus + * - coinbaseLinkAccount (mismatched state code) + * - coinbaseInitialize (no-token early return) + */ + +import configureTestStore from '@test/store'; +import { + isInvalidTokenError, + coinbaseParseErrorToString, + coinbaseErrorIncludesErrorParams, + coinbaseGetFiatAmount, + coinbaseDisconnectAccount, + coinbaseRefreshToken, + coinbaseGetUser, + coinbaseGetAccountsAndBalance, + coinbaseGetTransactionsByAccount, + coinbaseCreateAddress, + coinbaseSendTransaction, + coinbaseClearSendTransactionStatus, + coinbaseLinkAccount, + coinbaseInitialize, +} from './coinbase.effects'; +import {COINBASE_ENV} from '../../api/coinbase/coinbase.constants'; +import {CoinbaseEnvironment} from '../../api/coinbase/coinbase.types'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +jest.mock('../../managers/LogManager', () => ({ + logManager: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../analytics/analytics.effects', () => ({ + Analytics: { + track: jest.fn(() => ({type: 'ANALYTICS/TRACK'})), + }, +})); + +jest.mock('../../api/coinbase', () => ({ + __esModule: true, + default: { + getOauthStateCode: jest.fn(() => 'valid-state'), + getAccessToken: jest.fn(), + getRefreshToken: jest.fn(), + revokeToken: jest.fn(), + getCurrentUser: jest.fn(), + getAccounts: jest.fn(), + getTransactions: jest.fn(), + getNewAddress: jest.fn(), + sendTransaction: jest.fn(), + payInvoice: jest.fn(), + getFiatCurrencies: jest.fn(), + getExchangeRates: jest.fn(), + }, +})); + +// Import after mock so we can control its methods +import CoinbaseAPI from '../../api/coinbase'; +const MockCoinbaseAPI = CoinbaseAPI as jest.Mocked; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Minimal Coinbase token shape */ +const makeToken = () => ({ + access_token: 'access-abc', + refresh_token: 'refresh-abc', + token_type: 'Bearer', + expires_in: 7200, + scope: 'wallet:accounts:read', +}); + +/** Minimal error object */ +const makeError = (id: string, message = 'some error'): any => ({ + errors: [{id, message}], +}); + +const _SENTINEL = Symbol('use-real-token'); + +/** Full COINBASE state with a valid token (all required fields populated). + * Pass `null` as tokenValue to create a no-token state. */ +const fullCoinbaseState = (tokenValue: any = _SENTINEL) => { + const tok = tokenValue === _SENTINEL ? makeToken() : tokenValue; + return { + isApiLoading: false, + getAccessTokenStatus: null, + getAccessTokenError: null, + getRefreshTokenStatus: null, + getRefreshTokenError: null, + getUserStatus: null, + getUserError: null, + getAccountsStatus: null, + getAccountsError: null, + getTransactionsStatus: null, + getTransactionsError: null, + createAddressStatus: null, + createAddressError: null, + sendTransactionStatus: null, + sendTransactionError: null, + payInvoiceStatus: null, + payInvoiceError: null, + exchangeRates: null, + hideTotalBalance: false, + fiatCurrency: 'USD', + blockchainNetwork: 'ethereum', + token: { + [CoinbaseEnvironment.production]: tok, + [CoinbaseEnvironment.sandbox]: tok, + }, + user: { + [CoinbaseEnvironment.production]: null, + [CoinbaseEnvironment.sandbox]: null, + }, + accounts: { + [CoinbaseEnvironment.production]: null, + [CoinbaseEnvironment.sandbox]: null, + }, + transactions: { + [CoinbaseEnvironment.production]: null, + [CoinbaseEnvironment.sandbox]: null, + }, + balance: { + [CoinbaseEnvironment.production]: null, + [CoinbaseEnvironment.sandbox]: null, + }, + }; +}; + +/** Store with a real Coinbase token so API calls aren't short-circuited */ +const storeWithToken = () => + configureTestStore({COINBASE: fullCoinbaseState()}); + +// --------------------------------------------------------------------------- +// isInvalidTokenError — pure utility +// --------------------------------------------------------------------------- + +describe('isInvalidTokenError', () => { + it('returns true when error object has id "invalid_token"', () => { + expect(isInvalidTokenError(makeError('invalid_token'))).toBe(true); + }); + + it('returns false when error object has a different id', () => { + expect(isInvalidTokenError(makeError('expired_token'))).toBe(false); + }); + + it('returns true when error is a string containing "Unauthorized"', () => { + expect(isInvalidTokenError('Unauthorized request')).toBe(true); + }); + + it('returns false when error is a string that does not contain "Unauthorized"', () => { + expect(isInvalidTokenError('Network Error')).toBe(false); + }); + + it('returns false when errors array is empty', () => { + expect(isInvalidTokenError({errors: []})).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseParseErrorToString — pure utility +// --------------------------------------------------------------------------- + +describe('coinbaseParseErrorToString', () => { + it('returns the string directly when error is a string', () => { + expect(coinbaseParseErrorToString('plain string error')).toBe( + 'plain string error', + ); + }); + + it('returns error_description when present', () => { + expect( + coinbaseParseErrorToString({error_description: 'token invalid'}), + ).toBe('token invalid'); + }); + + it('concatenates multiple error messages from the errors array', () => { + const error = { + errors: [ + {id: 'A', message: 'First error'}, + {id: 'B', message: 'Second error'}, + ], + }; + expect(coinbaseParseErrorToString(error)).toBe('First error. Second error'); + }); + + it('returns single message when there is exactly one error', () => { + expect(coinbaseParseErrorToString(makeError('X', 'Only error'))).toBe( + 'Only error', + ); + }); + + it('returns "Network Error" when error has no recognisable shape', () => { + expect(coinbaseParseErrorToString({})).toBe('Network Error'); + expect(coinbaseParseErrorToString(null)).toBe('Network Error'); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseErrorIncludesErrorParams — pure utility +// --------------------------------------------------------------------------- + +describe('coinbaseErrorIncludesErrorParams', () => { + const error = makeError('rate_limit_exceeded', 'Rate limit exceeded'); + + it('returns true when id matches', () => { + expect( + coinbaseErrorIncludesErrorParams(error, {id: 'rate_limit_exceeded'}), + ).toBe(true); + }); + + it('returns true when message matches', () => { + expect( + coinbaseErrorIncludesErrorParams(error, {message: 'Rate limit exceeded'}), + ).toBe(true); + }); + + it('returns true when both id and message match', () => { + expect( + coinbaseErrorIncludesErrorParams(error, { + id: 'rate_limit_exceeded', + message: 'Rate limit exceeded', + }), + ).toBe(true); + }); + + it('returns false when id does not match', () => { + expect(coinbaseErrorIncludesErrorParams(error, {id: 'other_error'})).toBe( + false, + ); + }); + + it('returns false when both id and message are given but message does not match', () => { + expect( + coinbaseErrorIncludesErrorParams(error, { + id: 'rate_limit_exceeded', + message: 'Wrong message', + }), + ).toBe(false); + }); + + it('returns falsy when error has no errors array', () => { + // The function calls .some on undefined — returns undefined which is falsy + expect( + coinbaseErrorIncludesErrorParams({}, {id: 'rate_limit_exceeded'}), + ).toBeFalsy(); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseGetFiatAmount — pure function +// --------------------------------------------------------------------------- + +describe('coinbaseGetFiatAmount', () => { + const exchangeRates: any = { + data: { + rates: {BTC: '0.00002', ETH: '0.001', USD: '1'}, + currency: 'USD', + }, + }; + + it('returns 0 when exchangeRates is null', () => { + expect(coinbaseGetFiatAmount(1, 'BTC', null)).toBe(0.0); + }); + + it('converts BTC amount to fiat correctly', () => { + // amount / rate = 1 / 0.00002 = 50000 + expect(coinbaseGetFiatAmount(1, 'BTC', exchangeRates)).toBeCloseTo(50000); + }); + + it('converts ETH amount to fiat correctly', () => { + // 0.5 / 0.001 = 500 + expect(coinbaseGetFiatAmount(0.5, 'ETH', exchangeRates)).toBeCloseTo(500); + }); + + it('returns 0 when amount is 0', () => { + expect(coinbaseGetFiatAmount(0, 'BTC', exchangeRates)).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseDisconnectAccount +// --------------------------------------------------------------------------- + +describe('coinbaseDisconnectAccount', () => { + beforeEach(() => jest.clearAllMocks()); + + it('dispatches revokeTokenSuccess even when revokeToken API throws', async () => { + (MockCoinbaseAPI.revokeToken as jest.Mock).mockRejectedValueOnce( + new Error('network error'), + ); + const store = storeWithToken(); + await store.dispatch(coinbaseDisconnectAccount()); + + // State should be cleared (token nulled out) via DISCONNECT_ACCOUNT_SUCCESS + expect(store.getState().COINBASE.token[COINBASE_ENV]).toBeNull(); + }); + + it('calls revokeToken when token exists', async () => { + (MockCoinbaseAPI.revokeToken as jest.Mock).mockResolvedValueOnce({}); + const store = storeWithToken(); + await store.dispatch(coinbaseDisconnectAccount()); + expect(MockCoinbaseAPI.revokeToken).toHaveBeenCalledTimes(1); + }); + + it('does NOT call revokeToken when no token in state', async () => { + const store = configureTestStore({COINBASE: fullCoinbaseState(null)}); + await store.dispatch(coinbaseDisconnectAccount()); + expect(MockCoinbaseAPI.revokeToken).not.toHaveBeenCalled(); + // State should still be cleared + expect(store.getState().COINBASE.token[COINBASE_ENV]).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseRefreshToken +// --------------------------------------------------------------------------- + +describe('coinbaseRefreshToken', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early without calling API when no token in state', async () => { + const store = configureTestStore({COINBASE: fullCoinbaseState(null)}); + await store.dispatch(coinbaseRefreshToken()); + expect(MockCoinbaseAPI.getRefreshToken).not.toHaveBeenCalled(); + }); + + it('dispatches refreshTokenSuccess when API call succeeds', async () => { + const newToken = makeToken(); + (MockCoinbaseAPI.getRefreshToken as jest.Mock).mockResolvedValueOnce( + newToken, + ); + const store = storeWithToken(); + await store.dispatch(coinbaseRefreshToken()); + expect(store.getState().COINBASE.token[COINBASE_ENV]).toEqual(newToken); + }); + + it('dispatches refreshTokenFailed and disconnects when API call fails', async () => { + (MockCoinbaseAPI.getRefreshToken as jest.Mock).mockRejectedValueOnce( + makeError('invalid_token'), + ); + // revokeToken called during disconnect + (MockCoinbaseAPI.revokeToken as jest.Mock).mockResolvedValueOnce({}); + const store = storeWithToken(); + await store.dispatch(coinbaseRefreshToken()); + // After failed refresh, disconnect is called → token becomes null + expect(store.getState().COINBASE.token[COINBASE_ENV]).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseGetUser +// --------------------------------------------------------------------------- + +describe('coinbaseGetUser', () => { + const mockUser: any = {data: {id: 'u1', name: 'Alice'}}; + + beforeEach(() => jest.clearAllMocks()); + + it('returns early when no token in state', async () => { + const store = configureTestStore({COINBASE: fullCoinbaseState(null)}); + await store.dispatch(coinbaseGetUser()); + expect(MockCoinbaseAPI.getCurrentUser).not.toHaveBeenCalled(); + }); + + it('dispatches userSuccess with user data on successful API call', async () => { + (MockCoinbaseAPI.getCurrentUser as jest.Mock).mockResolvedValueOnce( + mockUser, + ); + const store = storeWithToken(); + await store.dispatch(coinbaseGetUser()); + expect(store.getState().COINBASE.user[COINBASE_ENV]).toEqual(mockUser); + }); + + it('dispatches userFailed on generic error', async () => { + const error = makeError('unknown_error', 'Something went wrong'); + (MockCoinbaseAPI.getCurrentUser as jest.Mock).mockRejectedValueOnce(error); + const store = storeWithToken(); + await store.dispatch(coinbaseGetUser()); + expect(store.getState().COINBASE.getUserError).toEqual(error); + }); + + it('attempts token refresh on expired_token error', async () => { + const expiredError = makeError('expired_token'); + (MockCoinbaseAPI.getCurrentUser as jest.Mock) + .mockRejectedValueOnce(expiredError) // first call → expired + .mockResolvedValueOnce(mockUser); // recursive call after refresh + (MockCoinbaseAPI.getRefreshToken as jest.Mock).mockResolvedValueOnce( + makeToken(), + ); + const store = storeWithToken(); + await store.dispatch(coinbaseGetUser()); + expect(MockCoinbaseAPI.getRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('disconnects on revoked_token error', async () => { + const revokedError = makeError('revoked_token'); + (MockCoinbaseAPI.getCurrentUser as jest.Mock).mockRejectedValueOnce( + revokedError, + ); + (MockCoinbaseAPI.revokeToken as jest.Mock).mockResolvedValueOnce({}); + const store = storeWithToken(); + await store.dispatch(coinbaseGetUser()); + expect(store.getState().COINBASE.token[COINBASE_ENV]).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseGetAccountsAndBalance +// --------------------------------------------------------------------------- + +describe('coinbaseGetAccountsAndBalance', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early when no token in state', async () => { + const store = configureTestStore({COINBASE: fullCoinbaseState(null)}); + await store.dispatch(coinbaseGetAccountsAndBalance()); + expect(MockCoinbaseAPI.getAccounts).not.toHaveBeenCalled(); + }); + + it('stores only supported-currency wallet accounts in state', async () => { + const mockAccounts: any = { + data: [ + // BTC wallet — supported + { + id: 'acc-btc', + type: 'wallet', + balance: {amount: 1, currency: 'btc'}, + }, + // FIAT account — should be filtered out (type != wallet? actually no, checked by currency) + { + id: 'acc-unsupported', + type: 'wallet', + balance: {amount: 100, currency: 'FAKECOIN'}, + }, + // vault type — filtered out because type !== 'wallet'? Let's use type: 'vault' + { + id: 'acc-vault', + type: 'vault', + balance: {amount: 0.5, currency: 'eth'}, + }, + ], + }; + (MockCoinbaseAPI.getAccounts as jest.Mock).mockResolvedValueOnce( + mockAccounts, + ); + const store = storeWithToken(); + await store.dispatch(coinbaseGetAccountsAndBalance()); + const accounts = store.getState().COINBASE.accounts[COINBASE_ENV]; + // Only the btc wallet account is supported and type=wallet + expect(accounts).toBeDefined(); + expect(accounts!.find((a: any) => a.id === 'acc-btc')).toBeTruthy(); + expect(accounts!.find((a: any) => a.id === 'acc-unsupported')).toBeFalsy(); + expect(accounts!.find((a: any) => a.id === 'acc-vault')).toBeFalsy(); + }); + + it('dispatches accountsFailed on generic error', async () => { + const error = makeError('some_error'); + (MockCoinbaseAPI.getAccounts as jest.Mock).mockRejectedValueOnce(error); + const store = storeWithToken(); + await store.dispatch(coinbaseGetAccountsAndBalance()); + expect(store.getState().COINBASE.getAccountsError).toEqual(error); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseGetTransactionsByAccount +// --------------------------------------------------------------------------- + +describe('coinbaseGetTransactionsByAccount', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early when no token in state', async () => { + const store = configureTestStore({COINBASE: fullCoinbaseState(null)}); + await store.dispatch(coinbaseGetTransactionsByAccount('acc-1')); + expect(MockCoinbaseAPI.getTransactions).not.toHaveBeenCalled(); + }); + + it('returns early from cache when forceUpdate is false and transactions exist', async () => { + const cachedTxState = { + ...fullCoinbaseState(), + transactions: { + [CoinbaseEnvironment.production]: { + 'acc-1': {data: [{id: 'tx-1'}], pagination: {}}, + }, + [CoinbaseEnvironment.sandbox]: { + 'acc-1': {data: [{id: 'tx-1'}], pagination: {}}, + }, + }, + }; + const store = configureTestStore({COINBASE: cachedTxState}); + await store.dispatch( + coinbaseGetTransactionsByAccount('acc-1', false /* forceUpdate */), + ); + expect(MockCoinbaseAPI.getTransactions).not.toHaveBeenCalled(); + }); + + it('fetches transactions when forceUpdate is true even if cache exists', async () => { + const freshTxs: any = {data: [{id: 'tx-fresh'}], pagination: {}}; + (MockCoinbaseAPI.getTransactions as jest.Mock).mockResolvedValueOnce( + freshTxs, + ); + const cachedTxState = { + ...fullCoinbaseState(), + transactions: { + [CoinbaseEnvironment.production]: { + 'acc-1': {data: [{id: 'tx-old'}], pagination: {}}, + }, + [CoinbaseEnvironment.sandbox]: { + 'acc-1': {data: [{id: 'tx-old'}], pagination: {}}, + }, + }, + }; + const store = configureTestStore({COINBASE: cachedTxState}); + await store.dispatch( + coinbaseGetTransactionsByAccount('acc-1', true /* forceUpdate */), + ); + expect(MockCoinbaseAPI.getTransactions).toHaveBeenCalledTimes(1); + }); + + it('fetches transactions when cache is empty', async () => { + const txs: any = {data: [{id: 'tx-1'}], pagination: {}}; + (MockCoinbaseAPI.getTransactions as jest.Mock).mockResolvedValueOnce(txs); + const store = storeWithToken(); + await store.dispatch(coinbaseGetTransactionsByAccount('acc-new')); + expect(MockCoinbaseAPI.getTransactions).toHaveBeenCalledTimes(1); + expect( + store.getState().COINBASE.transactions[COINBASE_ENV]?.['acc-new'], + ).toEqual(txs); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseCreateAddress +// --------------------------------------------------------------------------- + +describe('coinbaseCreateAddress', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early when no token in state', async () => { + const store = configureTestStore({COINBASE: fullCoinbaseState(null)}); + const result = await store.dispatch(coinbaseCreateAddress('acc-1')); + expect(result).toBeUndefined(); + expect(MockCoinbaseAPI.getNewAddress).not.toHaveBeenCalled(); + }); + + it('returns the address on success', async () => { + (MockCoinbaseAPI.getNewAddress as jest.Mock).mockResolvedValueOnce({ + data: {address: '0xNEW_ADDRESS'}, + }); + const store = storeWithToken(); + const result = await store.dispatch(coinbaseCreateAddress('acc-1')); + expect(result).toBe('0xNEW_ADDRESS'); + }); + + it('dispatches createAddressFailed on generic error', async () => { + const error = makeError('create_failed'); + (MockCoinbaseAPI.getNewAddress as jest.Mock).mockRejectedValueOnce(error); + const store = storeWithToken(); + await store.dispatch(coinbaseCreateAddress('acc-1')); + expect(store.getState().COINBASE.createAddressError).toEqual(error); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseSendTransaction +// --------------------------------------------------------------------------- + +describe('coinbaseSendTransaction', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early when no token in state', async () => { + const store = configureTestStore({COINBASE: fullCoinbaseState(null)}); + await store.dispatch(coinbaseSendTransaction('acc-1', {currency: 'btc'})); + expect(MockCoinbaseAPI.sendTransaction).not.toHaveBeenCalled(); + }); + + it('dispatches sendTransactionSuccess on success', async () => { + (MockCoinbaseAPI.sendTransaction as jest.Mock).mockResolvedValueOnce({}); + const store = storeWithToken(); + await store.dispatch(coinbaseSendTransaction('acc-1', {currency: 'btc'})); + expect(store.getState().COINBASE.sendTransactionStatus).toBe('success'); + }); + + it('dispatches sendTransactionFailed on generic error', async () => { + const error = makeError('tx_failed', 'Transaction failed'); + (MockCoinbaseAPI.sendTransaction as jest.Mock).mockRejectedValueOnce(error); + const store = storeWithToken(); + await store.dispatch(coinbaseSendTransaction('acc-1', {currency: 'btc'})); + expect(store.getState().COINBASE.sendTransactionError).toEqual(error); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseClearSendTransactionStatus +// --------------------------------------------------------------------------- + +describe('coinbaseClearSendTransactionStatus', () => { + it('clears the sendTransactionStatus in state', async () => { + const store = storeWithToken(); + // Put the state into a non-null status first + (MockCoinbaseAPI.sendTransaction as jest.Mock).mockResolvedValueOnce({}); + await store.dispatch(coinbaseSendTransaction('acc-1', {})); + expect(store.getState().COINBASE.sendTransactionStatus).toBe('success'); + + await store.dispatch(coinbaseClearSendTransactionStatus()); + expect(store.getState().COINBASE.sendTransactionStatus).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseLinkAccount +// --------------------------------------------------------------------------- + +describe('coinbaseLinkAccount', () => { + beforeEach(() => jest.clearAllMocks()); + + it('dispatches accessTokenFailed when OAuth state codes do not match', async () => { + (MockCoinbaseAPI.getOauthStateCode as jest.Mock).mockReturnValueOnce( + 'expected-state', + ); + const store = configureTestStore({}); + await expect( + store.dispatch(coinbaseLinkAccount('code-123', 'wrong-state')), + ).rejects.toBeDefined(); + expect(store.getState().COINBASE.getAccessTokenError).toBeDefined(); + expect(store.getState().COINBASE.getAccessTokenError?.errors?.[0]?.id).toBe( + 'STATE_INCORRECT', + ); + }); + + it('dispatches accessTokenSuccess when state matches and API succeeds', async () => { + const code = 'valid-code'; + const state = 'valid-state'; + const newToken = makeToken(); + (MockCoinbaseAPI.getOauthStateCode as jest.Mock).mockReturnValue(state); + (MockCoinbaseAPI.getAccessToken as jest.Mock).mockResolvedValueOnce( + newToken, + ); + // Stub downstream calls that happen after token success + (MockCoinbaseAPI.getCurrentUser as jest.Mock).mockResolvedValueOnce({ + data: {id: 'u1'}, + }); + (MockCoinbaseAPI.getFiatCurrencies as jest.Mock).mockResolvedValueOnce({ + data: [], + }); + (MockCoinbaseAPI.getExchangeRates as jest.Mock).mockResolvedValueOnce({ + data: {rates: {}, currency: 'USD'}, + }); + (MockCoinbaseAPI.getAccounts as jest.Mock).mockResolvedValueOnce({ + data: [], + }); + + const store = configureTestStore({}); + await store.dispatch(coinbaseLinkAccount(code, state)); + expect(store.getState().COINBASE.token[COINBASE_ENV]).toEqual(newToken); + }); +}); + +// --------------------------------------------------------------------------- +// coinbaseInitialize +// --------------------------------------------------------------------------- + +describe('coinbaseInitialize', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early without any API calls when no token in state', async () => { + const store = configureTestStore({COINBASE: fullCoinbaseState(null)}); + await store.dispatch(coinbaseInitialize()); + expect(MockCoinbaseAPI.getCurrentUser).not.toHaveBeenCalled(); + expect(MockCoinbaseAPI.getAccounts).not.toHaveBeenCalled(); + }); + + it('calls getUser, exchange rates and accounts when token exists', async () => { + (MockCoinbaseAPI.getCurrentUser as jest.Mock).mockResolvedValueOnce({ + data: {id: 'u1'}, + }); + (MockCoinbaseAPI.getFiatCurrencies as jest.Mock).mockResolvedValueOnce({ + data: [], + }); + (MockCoinbaseAPI.getExchangeRates as jest.Mock).mockResolvedValueOnce({ + data: {rates: {}, currency: 'USD'}, + }); + (MockCoinbaseAPI.getAccounts as jest.Mock).mockResolvedValueOnce({ + data: [], + }); + const store = storeWithToken(); + await store.dispatch(coinbaseInitialize()); + expect(MockCoinbaseAPI.getCurrentUser).toHaveBeenCalledTimes(1); + expect(MockCoinbaseAPI.getAccounts).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/store/contact/contact.effects.spec.ts b/src/store/contact/contact.effects.spec.ts new file mode 100644 index 0000000000..58b2e77ce3 --- /dev/null +++ b/src/store/contact/contact.effects.spec.ts @@ -0,0 +1,416 @@ +/** + * Tests for contact.effects.ts + * + * Covers all five migration effects: + * - startContactMigration + * - startContactV2Migration + * - startContactTokenAddressMigration + * - startContactBridgeUsdcMigration + * - startContactPolMigration + */ + +import configureTestStore from '@test/store'; +import {createContact} from './contact.actions'; +import { + startContactMigration, + startContactV2Migration, + startContactTokenAddressMigration, + startContactBridgeUsdcMigration, + startContactPolMigration, +} from './contact.effects'; +import {tokenManager} from '../../managers/TokenManager'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +jest.mock('../../managers/LogManager', () => ({ + logManager: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../../managers/TokenManager', () => ({ + tokenManager: { + getTokenOptions: jest.fn(() => ({tokenDataByAddress: {}})), + }, +})); + +// Sentry is already mocked in test/setup.js + +// --------------------------------------------------------------------------- +// Helper: build a minimal ContactRowProps +// --------------------------------------------------------------------------- +const makeContact = (overrides: Partial = {}) => ({ + address: '0xABC123', + coin: 'btc', + chain: 'btc', + network: 'livenet', + name: 'Alice', + ...overrides, +}); + +// --------------------------------------------------------------------------- +// startContactMigration +// --------------------------------------------------------------------------- +describe('startContactMigration', () => { + beforeEach(() => jest.clearAllMocks()); + + it('adds chain = coin for a UTXO coin that is already supported', async () => { + // 'btc' is in BitpaySupportedUtxoCoins → chain should become 'btc' + const store = configureTestStore({}); + store.dispatch(createContact(makeContact({coin: 'btc', chain: ''}))); + + await store.dispatch(startContactMigration()); + + const {list} = store.getState().CONTACT; + expect(list).toHaveLength(1); + expect(list[0].chain).toBe('btc'); + }); + + it('adds chain = coin for an OtherBitpaySupportedCoin (e.g. xrp)', async () => { + const store = configureTestStore({}); + store.dispatch( + createContact(makeContact({coin: 'xrp', chain: '', address: '0xXRP'})), + ); + + await store.dispatch(startContactMigration()); + + const {list} = store.getState().CONTACT; + expect(list[0].chain).toBe('xrp'); + }); + + it('falls back to "eth" for an unknown coin', async () => { + const store = configureTestStore({}); + store.dispatch( + createContact( + makeContact({coin: 'unknowncoin', chain: '', address: '0xUNK'}), + ), + ); + + await store.dispatch(startContactMigration()); + + const {list} = store.getState().CONTACT; + expect(list[0].chain).toBe('eth'); + }); + + it('resolves even when contact list is empty', async () => { + const store = configureTestStore({}); + await expect( + store.dispatch(startContactMigration()), + ).resolves.toBeUndefined(); + expect(store.getState().CONTACT.list).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// startContactV2Migration +// --------------------------------------------------------------------------- +describe('startContactV2Migration', () => { + beforeEach(() => jest.clearAllMocks()); + + it('merges duplicate addresses into a single contact with combined names', async () => { + const sharedAddress = '0xSHARED'; + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({ + address: sharedAddress, + name: 'Alice', + coin: 'eth', + chain: 'eth', + }), + makeContact({ + address: sharedAddress, + name: 'Bob', + coin: 'eth', + chain: 'eth', + }), + ], + }, + }); + + await store.dispatch(startContactV2Migration()); + + const {list} = store.getState().CONTACT; + expect(list).toHaveLength(1); + expect(list[0].name).toBe('Alice - Bob'); + expect(list[0].address).toBe(sharedAddress); + }); + + it('keeps unique addresses as separate contacts', async () => { + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({address: '0xAAA', name: 'Alice'}), + makeContact({address: '0xBBB', name: 'Bob'}), + ], + }, + }); + + await store.dispatch(startContactV2Migration()); + + const {list} = store.getState().CONTACT; + expect(list).toHaveLength(2); + }); + + it('sets notes to "EVM compatible address\\n" for EVM addresses', async () => { + // ethers isAddress mock returns true for any address + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({ + address: '0x1234567890123456789012345678901234567890', + coin: 'eth', + chain: 'eth', + }), + ], + }, + }); + + await store.dispatch(startContactV2Migration()); + + const {list} = store.getState().CONTACT; + // notes should be the EVM string (IsValidEVMAddress uses ethers.utils.isAddress, mocked → true) + expect(list[0].notes).toBe('EVM compatible address\n'); + }); + + it('resolves even when contact list is empty', async () => { + const store = configureTestStore({}); + await expect( + store.dispatch(startContactV2Migration()), + ).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// startContactTokenAddressMigration +// --------------------------------------------------------------------------- +describe('startContactTokenAddressMigration', () => { + beforeEach(() => jest.clearAllMocks()); + + it('resolves successfully with no contacts', async () => { + const store = configureTestStore({}); + await expect( + store.dispatch(startContactTokenAddressMigration()), + ).resolves.toBeUndefined(); + }); + + it('adds tokenAddress for a matching token in an EVM chain', async () => { + const mockTokenAddress = '0xTOKEN_ADDRESS'; + (tokenManager.getTokenOptions as jest.Mock).mockReturnValueOnce({ + tokenDataByAddress: { + [mockTokenAddress]: { + address: mockTokenAddress, + coin: 'usdc', + chain: 'eth', + }, + }, + }); + + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({ + address: '0xUSER', + coin: 'usdc', + chain: 'eth', + tokenAddress: undefined, + }), + ], + }, + }); + + await store.dispatch(startContactTokenAddressMigration()); + + const {list} = store.getState().CONTACT; + expect(list[0].tokenAddress).toBe(mockTokenAddress); + }); + + it('does NOT add tokenAddress for a non-EVM chain', async () => { + (tokenManager.getTokenOptions as jest.Mock).mockReturnValueOnce({ + tokenDataByAddress: { + '0xTOKEN': {address: '0xTOKEN', coin: 'btc', chain: 'btc'}, + }, + }); + + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({coin: 'btc', chain: 'btc', tokenAddress: undefined}), + ], + }, + }); + + await store.dispatch(startContactTokenAddressMigration()); + + const {list} = store.getState().CONTACT; + // btc is not an EVM chain — tokenAddress should remain undefined + expect(list[0].tokenAddress).toBeUndefined(); + }); + + it('resolves (does not reject) even when tokenManager throws', async () => { + (tokenManager.getTokenOptions as jest.Mock).mockImplementationOnce(() => { + throw new Error('tokenManager failure'); + }); + + const store = configureTestStore({ + CONTACT: { + list: [makeContact({coin: 'usdc', chain: 'eth'})], + }, + }); + + await expect( + store.dispatch(startContactTokenAddressMigration()), + ).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// startContactBridgeUsdcMigration +// --------------------------------------------------------------------------- +describe('startContactBridgeUsdcMigration', () => { + const bridgeUsdcTokenAddress = '0x2791bca1f2de4661ed88a30c99a7a9449aa84174'; + + beforeEach(() => jest.clearAllMocks()); + + it('renames coin to "usdc.e" for contacts with the bridge USDC token address', async () => { + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({ + address: '0xUSER', + coin: 'usdc', + chain: 'matic', + tokenAddress: bridgeUsdcTokenAddress, + }), + ], + }, + }); + + await store.dispatch(startContactBridgeUsdcMigration()); + + const {list} = store.getState().CONTACT; + expect(list[0].coin).toBe('usdc.e'); + }); + + it('leaves other contacts untouched', async () => { + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({ + address: '0xOTHER', + coin: 'usdc', + chain: 'eth', + tokenAddress: '0xDIFFERENT_ADDRESS', + }), + ], + }, + }); + + await store.dispatch(startContactBridgeUsdcMigration()); + + const {list} = store.getState().CONTACT; + expect(list[0].coin).toBe('usdc'); + }); + + it('handles mixed contacts correctly', async () => { + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({ + address: '0xBRIDGE', + coin: 'usdc', + chain: 'matic', + tokenAddress: bridgeUsdcTokenAddress, + }), + makeContact({ + address: '0xNORMAL', + coin: 'eth', + chain: 'eth', + tokenAddress: undefined, + }), + ], + }, + }); + + await store.dispatch(startContactBridgeUsdcMigration()); + + const {list} = store.getState().CONTACT; + const bridge = list.find(c => c.address === '0xBRIDGE'); + const normal = list.find(c => c.address === '0xNORMAL'); + expect(bridge?.coin).toBe('usdc.e'); + expect(normal?.coin).toBe('eth'); + }); + + it('resolves even when contact list is empty', async () => { + const store = configureTestStore({}); + await expect( + store.dispatch(startContactBridgeUsdcMigration()), + ).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// startContactPolMigration +// --------------------------------------------------------------------------- +describe('startContactPolMigration', () => { + beforeEach(() => jest.clearAllMocks()); + + it('renames coin from "matic" to "pol"', async () => { + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({address: '0xMATIC', coin: 'matic', chain: 'matic'}), + ], + }, + }); + + await store.dispatch(startContactPolMigration()); + + const {list} = store.getState().CONTACT; + expect(list[0].coin).toBe('pol'); + }); + + it('leaves non-matic contacts untouched', async () => { + const store = configureTestStore({ + CONTACT: { + list: [makeContact({coin: 'eth', chain: 'eth'})], + }, + }); + + await store.dispatch(startContactPolMigration()); + + const {list} = store.getState().CONTACT; + expect(list[0].coin).toBe('eth'); + }); + + it('handles mixed matic and non-matic contacts', async () => { + const store = configureTestStore({ + CONTACT: { + list: [ + makeContact({address: '0xM', coin: 'matic', chain: 'matic'}), + makeContact({address: '0xE', coin: 'eth', chain: 'eth'}), + makeContact({address: '0xB', coin: 'btc', chain: 'btc'}), + ], + }, + }); + + await store.dispatch(startContactPolMigration()); + + const {list} = store.getState().CONTACT; + expect(list.find(c => c.address === '0xM')?.coin).toBe('pol'); + expect(list.find(c => c.address === '0xE')?.coin).toBe('eth'); + expect(list.find(c => c.address === '0xB')?.coin).toBe('btc'); + }); + + it('resolves even when contact list is empty', async () => { + const store = configureTestStore({}); + await expect( + store.dispatch(startContactPolMigration()), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/store/location/location.effects.ts b/src/store/location/location.effects.ts index 418fbb6177..42fe5d8c21 100644 --- a/src/store/location/location.effects.ts +++ b/src/store/location/location.effects.ts @@ -16,35 +16,35 @@ export const isEuCountry = (countryShortCode: string | undefined): boolean => { export const getLocationData = (): Effect> => async dispatch => { - try { - const {data: locationData} = await axios.get( - 'https://bitpay.com/location/ipAddress', - { - headers: { - ...NO_CACHE_HEADERS, - 'Content-Type': 'application/json', + try { + const {data: locationData} = await axios.get( + 'https://bitpay.com/location/ipAddress', + { + headers: { + ...NO_CACHE_HEADERS, + 'Content-Type': 'application/json', + }, }, - }, - ); + ); - const normalizedLocationData: LocationData = { - countryShortCode: locationData.country, - isEuCountry: isEuCountry(locationData.country), - stateShortCode: locationData.state ?? undefined, - cityFullName: locationData.city ?? undefined, - locationFullName: locationData.locationString ?? undefined, - }; + const normalizedLocationData: LocationData = { + countryShortCode: locationData.country, + isEuCountry: isEuCountry(locationData.country), + stateShortCode: locationData.state ?? undefined, + cityFullName: locationData.city ?? undefined, + locationFullName: locationData.locationString ?? undefined, + }; - logManager.info('getLocationData', locationData.country); - await dispatch( - LocationActions.successGetLocation({ - locationData: normalizedLocationData, - }), - ); - return normalizedLocationData; - } catch (err) { - const errStr = err instanceof Error ? err.message : JSON.stringify(err); - logManager.error('getLocationData', errStr); - return undefined; - } -}; + logManager.info('getLocationData', locationData.country); + await dispatch( + LocationActions.successGetLocation({ + locationData: normalizedLocationData, + }), + ); + return normalizedLocationData; + } catch (err) { + const errStr = err instanceof Error ? err.message : JSON.stringify(err); + logManager.error('getLocationData', errStr); + return undefined; + } + }; diff --git a/src/store/portfolio/portfolio.effects.spec.ts b/src/store/portfolio/portfolio.effects.spec.ts new file mode 100644 index 0000000000..0789755379 --- /dev/null +++ b/src/store/portfolio/portfolio.effects.spec.ts @@ -0,0 +1,746 @@ +/** + * Tests for portfolio.effects.ts + * + * Strategy: + * - The portfolio effects are heavily UI/navigation coupled and the import + * chain through the full Redux store is very deep. We mock everything at + * the module level and use a minimal Redux thunk dispatcher. + * - We focus on the guard branches (early returns) in the three exported + * effects: maybePopulatePortfolioForWallets, populatePortfolio, + * and preparePortfolioFiatRateCachesForQuoteCurrencySwitch. + */ + +// --------------------------------------------------------------------------- +// Module-level mocks — MUST be before any imports that trigger the chain +// --------------------------------------------------------------------------- + +// Prevent deep react-native module resolution issues +jest.mock('react-native', () => ({ + AppState: { + currentState: 'active', + addEventListener: jest.fn(() => ({remove: jest.fn()})), + }, + DeviceEventEmitter: { + addListener: jest.fn(() => ({remove: jest.fn()})), + removeAllListeners: jest.fn(), + }, + Platform: {OS: 'android', Version: 24, select: (s: any) => s.android ?? null}, + NativeModules: {}, + StyleSheet: {create: (s: any) => s, flatten: (s: any) => s}, +})); + +// Note: portfolio.effects.ts imports from '..' (store index) for types only (Effect, RootState) +// These are TypeScript types so nothing runtime to mock here. + +// Mock navigation +jest.mock('../../navigation/NavigationService', () => ({ + navigationRef: { + isReady: jest.fn(() => true), + getCurrentRoute: jest.fn(() => ({name: 'Home'})), + getState: jest.fn(() => ({routes: [{name: 'Home'}]})), + }, +})); + +// Mock device emitter events constant +jest.mock('../../constants/device-emitter-events', () => ({ + DeviceEmitterEvents: {APP_LOCK_MODAL_DISMISSED: 'APP_LOCK_MODAL_DISMISSED'}, +})); + +// Mock constants/index (uses Platform.OS at module level) +jest.mock('../../constants', () => ({ + Network: {mainnet: 'livenet', testnet: 'testnet'}, + IS_ANDROID: true, + IS_IOS: false, +})); + +// Mock rate models +jest.mock('../rate/rate.models', () => ({ + getFiatRateSeriesCacheKey: jest.fn( + (fiatCode: string, coin: string, interval: string) => + `${(fiatCode || '').toUpperCase()}:${( + coin || '' + ).toLowerCase()}:${interval}`, + ), +})); + +jest.mock('../wallet/effects', () => ({ + startGetRates: jest.fn(() => () => Promise.resolve({})), + fetchFiatRateSeriesAllIntervals: jest.fn(() => () => Promise.resolve()), + fetchFiatRateSeriesInterval: jest.fn(() => () => Promise.resolve(true)), +})); + +// Mock rate actions +jest.mock('../rate/rate.actions', () => ({ + pruneFiatRateSeriesCache: jest.fn(() => ({type: 'MOCK_PRUNE'})), +})); + +// Mock transactions +jest.mock('../wallet/effects/transactions/transactions', () => ({ + GetTransactionHistory: jest.fn(() => () => Promise.resolve([])), + BWS_TX_HISTORY_LIMIT: 1001, +})); + +// Mock wallet utils currency +jest.mock('../wallet/utils/currency', () => ({ + GetPrecision: jest.fn(() => ({unitDecimals: 8})), +})); + +// Mock helper-methods +jest.mock('../../utils/helper-methods', () => ({ + getRateByCurrencyName: jest.fn(() => undefined), + getErrorString: jest.fn((e: any) => String(e)), + sleep: jest.fn(() => Promise.resolve()), + atomicToUnitString: jest.fn(() => '0'), + unitStringToAtomicBigInt: jest.fn(() => BigInt(0)), +})); + +// Mock portfolio pnl utils +jest.mock('../../utils/portfolio/core/pnl/snapshots', () => ({ + buildBalanceSnapshotsAsync: jest.fn(() => Promise.resolve([])), + computeBalanceSnapshotComputed: jest.fn(() => ({})), +})); + +jest.mock('../../utils/portfolio/core/pnl/rates', () => ({ + normalizeFiatRateSeriesCoin: jest.fn((c?: string) => (c || '').toLowerCase()), +})); + +jest.mock('../../utils/portfolio/assets', () => ({ + getWalletIdsToPopulateFromSnapshots: jest.fn(() => ({ + walletIdsToPopulate: [], + snapshotBalanceMismatchUpdates: {}, + })), + getSnapshotAtomicBalanceFromCryptoBalance: jest.fn(() => BigInt(0)), + getWalletLiveAtomicBalance: jest.fn(() => BigInt(0)), + getVisibleWalletsFromKeys: jest.fn(() => []), + getLatestSnapshot: jest.fn(() => undefined), +})); + +// Mock portfolio actions +jest.mock('./portfolio.actions', () => ({ + clearPortfolio: jest.fn(() => ({type: 'CLEAR_PORTFOLIO'})), + finishPopulatePortfolio: jest.fn(() => ({type: 'FINISH_POPULATE'})), + setSnapshotBalanceMismatchesByWalletIdUpdates: jest.fn(() => ({ + type: 'SET_MISMATCH', + })), + setWalletSnapshots: jest.fn(() => ({type: 'SET_SNAPSHOTS'})), + startPopulatePortfolio: jest.fn(args => ({ + type: 'START_POPULATE', + payload: args, + })), + updatePopulateProgress: jest.fn(() => ({type: 'UPDATE_PROGRESS'})), +})); + +// Mock portfolio utils +jest.mock('./portfolio.utils', () => ({ + shouldDisablePopulateForLargeHistory: jest.fn(() => false), +})); + +// Mock LogManager +jest.mock('../../managers/LogManager', () => ({ + logManager: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Now we can import the effects +// --------------------------------------------------------------------------- + +import { + maybePopulatePortfolioForWallets, + populatePortfolio, + preparePortfolioFiatRateCachesForQuoteCurrencySwitch, +} from './portfolio.effects'; + +// --------------------------------------------------------------------------- +// Minimal Redux thunk setup — avoids the full store import chain +// --------------------------------------------------------------------------- + +type AnyFn = (...args: any[]) => any; + +/** Build a minimal state-carrying store for testing thunks. */ +const makeMinimalStore = (state: Record) => { + let currentState = {...state}; + const dispatched: any[] = []; + + const getState = () => currentState; + const dispatch = (action: any): any => { + if (typeof action === 'function') { + return action(dispatch, getState); + } + dispatched.push(action); + return action; + }; + + return {dispatch, getState, dispatched}; +}; + +// --------------------------------------------------------------------------- +// State builder +// --------------------------------------------------------------------------- + +const makeState = (overrides: Record = {}) => ({ + APP: { + showPortfolioValue: true, + pinLockActive: false, + biometricLockActive: false, + lockAuthorizedUntil: null, + homeCarouselConfig: [], + defaultAltCurrency: {isoCode: 'USD'}, + altCurrencyList: [{isoCode: 'USD', name: 'US Dollar'}], + ...overrides.APP, + }, + PORTFOLIO: { + populateDisabled: false, + populateStatus: {inProgress: false}, + snapshotsByWalletId: {}, + snapshotBalanceMismatchesByWalletId: {}, + quoteCurrency: 'USD', + ...overrides.PORTFOLIO, + }, + WALLET: { + keys: {}, + ...overrides.WALLET, + }, + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + ...overrides.RATE, + }, + ...overrides, +}); + +// --------------------------------------------------------------------------- +// maybePopulatePortfolioForWallets – guard branches +// --------------------------------------------------------------------------- +describe('maybePopulatePortfolioForWallets – guard branches', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early when showPortfolioValue is false (portfolio disabled)', async () => { + const state = makeState({APP: {showPortfolioValue: false}}); + const {dispatch} = makeMinimalStore(state); + + await dispatch( + maybePopulatePortfolioForWallets({wallets: [], quoteCurrency: 'USD'}), + ); + + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); + + it('returns early when populateDisabled is true', async () => { + const state = makeState({ + PORTFOLIO: {populateDisabled: true, populateStatus: {inProgress: false}}, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch( + maybePopulatePortfolioForWallets({wallets: [], quoteCurrency: 'USD'}), + ); + + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); + + it('defers work when pinLockActive is true and lockAuthorizedUntil is undefined (not finite)', async () => { + const {DeviceEventEmitter} = require('react-native'); + // NOTE: Number(null) = 0 which is finite! Must use undefined (→ NaN, not finite) + const state = makeState({ + APP: { + showPortfolioValue: true, + pinLockActive: true, + biometricLockActive: false, + lockAuthorizedUntil: undefined, // undefined → NaN → not finite → triggers defer + homeCarouselConfig: [], + defaultAltCurrency: {isoCode: 'USD'}, + altCurrencyList: [], + }, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch( + maybePopulatePortfolioForWallets({ + wallets: [{id: 'w1'} as any], + quoteCurrency: 'USD', + }), + ); + + // deferPortfolioWorkUntilAppUnlock calls DeviceEventEmitter.addListener + expect(DeviceEventEmitter.addListener).toHaveBeenCalled(); + }); + + it('defers work when biometricLockActive is true and lockAuthorizedUntil is undefined', async () => { + const {DeviceEventEmitter} = require('react-native'); + const state = makeState({ + APP: { + showPortfolioValue: true, + pinLockActive: false, + biometricLockActive: true, + lockAuthorizedUntil: undefined, + homeCarouselConfig: [], + defaultAltCurrency: {isoCode: 'USD'}, + altCurrencyList: [], + }, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch( + maybePopulatePortfolioForWallets({wallets: [{id: 'w1'} as any]}), + ); + + expect(DeviceEventEmitter.addListener).toHaveBeenCalled(); + }); + + it('does NOT defer when lockAuthorizedUntil is a valid finite number (unlocked)', async () => { + const {DeviceEventEmitter} = require('react-native'); + const state = makeState({ + APP: { + showPortfolioValue: true, + pinLockActive: true, + biometricLockActive: false, + lockAuthorizedUntil: Date.now() + 60000, // finite → "authorized" + homeCarouselConfig: [], + defaultAltCurrency: {isoCode: 'USD'}, + altCurrencyList: [], + }, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch(maybePopulatePortfolioForWallets({wallets: []})); + + // Not deferred — lockAuthorizedUntil is finite + expect(DeviceEventEmitter.addListener).not.toHaveBeenCalled(); + }); + + it('returns early when wallets array is empty (after lock check)', async () => { + const state = makeState(); + const {dispatch} = makeMinimalStore(state); + + await dispatch( + maybePopulatePortfolioForWallets({wallets: [], quoteCurrency: 'USD'}), + ); + + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); + + it('returns early when populateStatus.inProgress is true', async () => { + const state = makeState({ + PORTFOLIO: {populateDisabled: false, populateStatus: {inProgress: true}}, + }); + const mockWallet: any = { + id: 'w1', + currencyAbbreviation: 'btc', + chain: 'btc', + balance: {sat: 1000000}, + }; + const {dispatch} = makeMinimalStore(state); + + await dispatch( + maybePopulatePortfolioForWallets({ + wallets: [mockWallet], + quoteCurrency: 'USD', + }), + ); + + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); + + it('uses quoteCurrency from args, resolves from PORTFOLIO.quoteCurrency or APP.defaultAltCurrency', async () => { + const state = makeState({ + PORTFOLIO: { + populateDisabled: false, + populateStatus: {inProgress: false}, + quoteCurrency: 'EUR', + snapshotsByWalletId: {}, + snapshotBalanceMismatchesByWalletId: {}, + }, + }); + const {dispatch} = makeMinimalStore(state); + + // Empty wallets → early return, resolveQuoteCurrency runs without error + await dispatch( + maybePopulatePortfolioForWallets({wallets: [], quoteCurrency: 'GBP'}), + ); + + expect(true).toBe(true); // no throw + }); + + it('dispatches snapshotBalanceMismatchUpdates when they exist', async () => { + const assetsModule = require('../../utils/portfolio/assets'); + assetsModule.getWalletIdsToPopulateFromSnapshots.mockReturnValueOnce({ + walletIdsToPopulate: [], + snapshotBalanceMismatchUpdates: { + w1: { + computedUnitsHeld: '1', + currentWalletBalance: '0.9', + delta: '0.1', + walletId: 'w1', + }, + }, + }); + + const state = makeState(); + const mockWallet: any = {id: 'w1'}; + const {dispatch} = makeMinimalStore(state); + + await dispatch( + maybePopulatePortfolioForWallets({ + wallets: [mockWallet], + quoteCurrency: 'USD', + }), + ); + + const { + setSnapshotBalanceMismatchesByWalletIdUpdates, + } = require('./portfolio.actions'); + expect(setSnapshotBalanceMismatchesByWalletIdUpdates).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// populatePortfolio – guard branches +// --------------------------------------------------------------------------- +describe('populatePortfolio – guard branches', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early when portfolio is disabled', async () => { + const state = makeState({APP: {showPortfolioValue: false}}); + const {dispatch} = makeMinimalStore(state); + + await dispatch(populatePortfolio()); + + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); + + it('returns early when populateDisabled is true', async () => { + const state = makeState({ + PORTFOLIO: {populateDisabled: true, populateStatus: {inProgress: false}}, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch(populatePortfolio()); + + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); + + it('defers and returns when app is locked (biometricLockActive, lockAuthorizedUntil=undefined)', async () => { + const {DeviceEventEmitter} = require('react-native'); + const state = makeState({ + APP: { + showPortfolioValue: true, + pinLockActive: false, + biometricLockActive: true, + lockAuthorizedUntil: undefined, // undefined → NaN → not finite → lock triggers + homeCarouselConfig: [], + defaultAltCurrency: {isoCode: 'USD'}, + altCurrencyList: [], + }, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch(populatePortfolio({quoteCurrency: 'USD'})); + + expect(DeviceEventEmitter.addListener).toHaveBeenCalled(); + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); + + it('returns early when populateStatus.inProgress is true', async () => { + const state = makeState({ + PORTFOLIO: {populateDisabled: false, populateStatus: {inProgress: true}}, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch(populatePortfolio({quoteCurrency: 'USD'})); + + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); + + it('returns early when no wallets have non-zero balance', async () => { + const assetsModule = require('../../utils/portfolio/assets'); + assetsModule.getVisibleWalletsFromKeys.mockReturnValueOnce([ + {id: 'w1', network: 'livenet', balance: {sat: 0, crypto: '0'}}, + ]); + + const state = makeState(); + const {dispatch} = makeMinimalStore(state); + + await dispatch(populatePortfolio({quoteCurrency: 'USD'})); + + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); + + it('uses walletIds filter to restrict which wallets are populated', async () => { + const assetsModule = require('../../utils/portfolio/assets'); + assetsModule.getVisibleWalletsFromKeys.mockReturnValueOnce([ + {id: 'w1', network: 'livenet', balance: {sat: 1000000}}, + {id: 'w2', network: 'livenet', balance: {sat: 0}}, + ]); + + const state = makeState(); + const {dispatch} = makeMinimalStore(state); + + // Only w1 is in the filter; w1 has non-zero balance → should proceed past the early return + await dispatch( + populatePortfolio({quoteCurrency: 'USD', walletIds: ['w1']}), + ); + + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).toHaveBeenCalled(); + }); + + it('resolves quoteCurrency from state when arg is not provided', async () => { + const state = makeState({ + PORTFOLIO: { + populateDisabled: false, + populateStatus: {inProgress: false}, + quoteCurrency: 'EUR', + snapshotsByWalletId: {}, + snapshotBalanceMismatchesByWalletId: {}, + }, + APP: { + showPortfolioValue: true, + pinLockActive: false, + biometricLockActive: false, + lockAuthorizedUntil: null, + homeCarouselConfig: [], + defaultAltCurrency: {isoCode: 'EUR'}, + altCurrencyList: [], + }, + }); + const {dispatch} = makeMinimalStore(state); + + // No wallets returned → early return, but resolveQuoteCurrency runs without error + await dispatch(populatePortfolio()); + + expect(true).toBe(true); // no throw + }); + + it('filters out non-mainnet wallets', async () => { + const assetsModule = require('../../utils/portfolio/assets'); + assetsModule.getVisibleWalletsFromKeys.mockReturnValueOnce([ + {id: 'w1', network: 'testnet', balance: {sat: 1000000}}, // non-mainnet + {id: 'w2', network: 'livenet', balance: {sat: 0}}, // mainnet but zero + ]); + + const state = makeState(); + const {dispatch} = makeMinimalStore(state); + + await dispatch(populatePortfolio({quoteCurrency: 'USD'})); + + // testnet wallet filtered → w2 zero balance → no wallets to populate + const {startGetRates} = require('../wallet/effects'); + expect(startGetRates).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// preparePortfolioFiatRateCachesForQuoteCurrencySwitch – guard branches +// --------------------------------------------------------------------------- +describe('preparePortfolioFiatRateCachesForQuoteCurrencySwitch – guard branches', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early when portfolio is disabled', async () => { + const state = makeState({APP: {showPortfolioValue: false}}); + const {dispatch} = makeMinimalStore(state); + + await dispatch(preparePortfolioFiatRateCachesForQuoteCurrencySwitch()); + + const {fetchFiatRateSeriesAllIntervals} = require('../wallet/effects'); + expect(fetchFiatRateSeriesAllIntervals).not.toHaveBeenCalled(); + }); + + it('returns early when populateDisabled is true', async () => { + const state = makeState({ + PORTFOLIO: {populateDisabled: true, populateStatus: {inProgress: false}}, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch(preparePortfolioFiatRateCachesForQuoteCurrencySwitch()); + + const {fetchFiatRateSeriesAllIntervals} = require('../wallet/effects'); + expect(fetchFiatRateSeriesAllIntervals).not.toHaveBeenCalled(); + }); + + it('returns early when populateStatus.inProgress is true', async () => { + const state = makeState({ + PORTFOLIO: {populateDisabled: false, populateStatus: {inProgress: true}}, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch(preparePortfolioFiatRateCachesForQuoteCurrencySwitch()); + + const {fetchFiatRateSeriesAllIntervals} = require('../wallet/effects'); + expect(fetchFiatRateSeriesAllIntervals).not.toHaveBeenCalled(); + }); + + it('uses provided quoteCurrency argument', async () => { + const state = makeState(); + const {dispatch} = makeMinimalStore(state); + + await dispatch( + preparePortfolioFiatRateCachesForQuoteCurrencySwitch({ + quoteCurrency: 'EUR', + }), + ); + + const {fetchFiatRateSeriesAllIntervals} = require('../wallet/effects'); + expect(fetchFiatRateSeriesAllIntervals).toHaveBeenCalledWith( + expect.objectContaining({fiatCode: 'EUR'}), + ); + }); + + it('falls back to state defaultAltCurrency when no quoteCurrency arg', async () => { + const state = makeState({ + APP: { + showPortfolioValue: true, + pinLockActive: false, + biometricLockActive: false, + lockAuthorizedUntil: null, + homeCarouselConfig: [], + defaultAltCurrency: {isoCode: 'GBP'}, + altCurrencyList: [], + }, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch(preparePortfolioFiatRateCachesForQuoteCurrencySwitch()); + + const {fetchFiatRateSeriesAllIntervals} = require('../wallet/effects'); + expect(fetchFiatRateSeriesAllIntervals).toHaveBeenCalledWith( + expect.objectContaining({fiatCode: 'GBP'}), + ); + }); + + it('falls back to USD when no quoteCurrency and no defaultAltCurrency', async () => { + const state = makeState({ + APP: { + showPortfolioValue: true, + pinLockActive: false, + biometricLockActive: false, + lockAuthorizedUntil: null, + homeCarouselConfig: [], + defaultAltCurrency: null, + altCurrencyList: [], + }, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch(preparePortfolioFiatRateCachesForQuoteCurrencySwitch()); + + const {fetchFiatRateSeriesAllIntervals} = require('../wallet/effects'); + expect(fetchFiatRateSeriesAllIntervals).toHaveBeenCalledWith( + expect.objectContaining({fiatCode: 'USD'}), + ); + }); + + it('fetches BTC bridge series for wallets with snapshots in different currency', async () => { + const assetsModule = require('../../utils/portfolio/assets'); + assetsModule.getVisibleWalletsFromKeys.mockReturnValueOnce([ + {id: 'w1', network: 'livenet'}, + ]); + assetsModule.getLatestSnapshot.mockReturnValueOnce({ + quoteCurrency: 'EUR', + timestamp: Date.now(), + }); + + const state = makeState({ + PORTFOLIO: { + populateDisabled: false, + populateStatus: {inProgress: false}, + snapshotsByWalletId: {w1: []}, + snapshotBalanceMismatchesByWalletId: {}, + quoteCurrency: 'USD', + }, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch( + preparePortfolioFiatRateCachesForQuoteCurrencySwitch({ + quoteCurrency: 'USD', + }), + ); + + const {fetchFiatRateSeriesAllIntervals} = require('../wallet/effects'); + // Should call once for USD (target) and once for EUR (source) + expect(fetchFiatRateSeriesAllIntervals).toHaveBeenCalledTimes(2); + expect(fetchFiatRateSeriesAllIntervals).toHaveBeenCalledWith( + expect.objectContaining({fiatCode: 'USD', currencyAbbreviation: 'btc'}), + ); + expect(fetchFiatRateSeriesAllIntervals).toHaveBeenCalledWith( + expect.objectContaining({fiatCode: 'EUR', allowedCoins: ['btc']}), + ); + }); + + it('skips snapshots in the same currency as target (no bridge fetch needed)', async () => { + const assetsModule = require('../../utils/portfolio/assets'); + assetsModule.getVisibleWalletsFromKeys.mockReturnValueOnce([ + {id: 'w1', network: 'livenet'}, + ]); + // Snapshot already in USD (same as target) + assetsModule.getLatestSnapshot.mockReturnValueOnce({ + quoteCurrency: 'USD', + timestamp: Date.now(), + }); + + const state = makeState({ + PORTFOLIO: { + populateDisabled: false, + populateStatus: {inProgress: false}, + snapshotsByWalletId: {w1: []}, + snapshotBalanceMismatchesByWalletId: {}, + quoteCurrency: 'USD', + }, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch( + preparePortfolioFiatRateCachesForQuoteCurrencySwitch({ + quoteCurrency: 'USD', + }), + ); + + const {fetchFiatRateSeriesAllIntervals} = require('../wallet/effects'); + // Only one call (for USD target), no bridge needed for same currency + expect(fetchFiatRateSeriesAllIntervals).toHaveBeenCalledTimes(1); + }); + + it('prunes stale fiat caches not in the allowed set', async () => { + // Cache has USD (current) and JPY (old stale currency) + const state = makeState({ + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: { + 'USD:btc:1D': {fetchedOn: Date.now(), points: []}, + 'JPY:btc:1D': {fetchedOn: Date.now(), points: []}, + }, + ratesCacheKey: {}, + }, + }); + const {dispatch} = makeMinimalStore(state); + + await dispatch( + preparePortfolioFiatRateCachesForQuoteCurrencySwitch({ + quoteCurrency: 'USD', + }), + ); + + // pruneFiatRateSeriesCache should have been called for JPY (not in allowedFiats) + const {pruneFiatRateSeriesCache} = require('../rate/rate.actions'); + expect(pruneFiatRateSeriesCache).toHaveBeenCalledWith( + expect.objectContaining({fiatCode: 'JPY'}), + ); + }); +}); diff --git a/src/store/scan/scan.spec.ts b/src/store/scan/scan.spec.ts index 091e8590e6..6a5720b6c2 100644 --- a/src/store/scan/scan.spec.ts +++ b/src/store/scan/scan.spec.ts @@ -1,8 +1,8 @@ -import * as logActions from '../log/log.actions'; import * as appActions from '../app/app.actions'; +import {logManager} from '../../managers/LogManager'; import * as Root from '../../Root'; import * as PayPro from '../wallet/effects/paypro/paypro'; -import {incomingData} from './scan.effects'; +import {incomingData, setBuyerProvidedEmail, goToAmount} from './scan.effects'; import configureTestStore from '@test/store'; import axios from 'axios'; import {BwcProvider} from '@/lib/bwc'; @@ -10,12 +10,17 @@ import { GetAddressNetwork, bitcoreLibs, } from '../wallet/effects/address/address'; -import {BitpaySupportedEvmCoins} from '@/constants/currencies'; +import { + BitpaySupportedEvmCoins, + BitpaySupportedSvmCoins, +} from '@/constants/currencies'; import {startCreateKey} from '../wallet/effects'; /** * incomingData Tests */ +jest.setTimeout(30000); + describe('incomingData', () => { beforeEach(() => { jest.clearAllMocks(); @@ -36,7 +41,7 @@ describe('incomingData', () => { const data = '9QRqDLbtasN5Wd37tRag7TKxMJVnCc2979pgs5CEBmGRmYU7kNrVynHdNtuBYxgfNgdj3EEJkHLbtc'; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promiseResult = await store.dispatch(incomingData(data)); expect(loggerSpy).toHaveBeenCalledWith( @@ -57,7 +62,7 @@ describe('incomingData', () => { {chain: 'btc', currencyAbbreviation: 'btc', isToken: false}, ]), ); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promiseResult = await store.dispatch(incomingData(data)); expect(loggerSpy).toHaveBeenCalledWith( @@ -84,7 +89,7 @@ describe('incomingData', () => { {chain: 'btc', currencyAbbreviation: 'btc', isToken: false}, ]), ); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promiseResult = await store.dispatch(incomingData(data)); expect(loggerSpy).toHaveBeenCalledWith( @@ -100,7 +105,7 @@ describe('incomingData', () => { const data = 'RTpopkn5KBnkxuT7x4ummDKx3Lu1LvbntddBC4ssDgaqP7DkojT8ccxaFQEXY4f3huFyMewhHZLbtc'; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const promiseResult = await store.dispatch(incomingData(data)); expect(loggerSpy).toHaveBeenCalledWith( '[scan] Incoming-data (redirect): Code to join to a multisig wallet', @@ -116,7 +121,7 @@ describe('incomingData', () => { '1|sick arch glare wheat anchor innocent garbage tape raccoon already obey ability|null|null|false|null', ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -214,7 +219,7 @@ describe('incomingData', () => { const store = configureTestStore({}); const mockData = {data: 'Your mock response data'}; // Your mock response (axios.get as jest.Mock).mockResolvedValue(mockData); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const payproSpy = jest.spyOn(PayPro, 'GetPayProOptions'); ( payproSpy as jest.MockedFunction @@ -322,7 +327,7 @@ describe('incomingData', () => { }, }; // Your mock response (axios.get as jest.Mock).mockResolvedValue(mockData); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const payproSpy = jest.spyOn(PayPro, 'GetPayProOptions'); ( payproSpy as jest.MockedFunction @@ -380,7 +385,7 @@ describe('incomingData', () => { }, }; // Your mock response (axios.get as jest.Mock).mockResolvedValue(mockData); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const payproSpy = jest.spyOn(PayPro, 'GetPayProOptions'); ( @@ -444,7 +449,7 @@ describe('incomingData', () => { }, }; // Your mock response (axios.get as jest.Mock).mockResolvedValue(mockData); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const payproSpy = jest.spyOn(PayPro, 'GetPayProOptions'); ( payproSpy as jest.MockedFunction @@ -472,7 +477,7 @@ describe('incomingData', () => { const store = configureTestStore({}); const mockData = {data: 'Your mock response data'}; // Your mock response (axios.get as jest.Mock).mockResolvedValue(mockData); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const payproSpy = jest.spyOn(PayPro, 'GetPayProOptions'); ( payproSpy as jest.MockedFunction @@ -513,7 +518,7 @@ describe('incomingData', () => { 'CcnxtMfvBHGTwoKGPSuezEuYNpGPJH6tjN', ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -524,7 +529,7 @@ describe('incomingData', () => { for (let i = 0; i < 2; i++) { expect(loggerSpy).toHaveBeenNthCalledWith( i + 1, - '[scan] Incoming-data: bch plain address', + '[scan] Incoming-data: BCH chain plain address', ); expect(navigationSpy).toHaveBeenNthCalledWith(i + 1, 'GlobalSelect', { context: 'scanner', @@ -540,6 +545,7 @@ describe('incomingData', () => { feePerKb: undefined, message: '', showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, }, type: 'address', }, @@ -550,7 +556,7 @@ describe('incomingData', () => { it('Should handle ETH plain Address', async () => { const data = ['0xb506c911deE6379e3d4c4d0F4A429a70523960Fd']; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -561,7 +567,7 @@ describe('incomingData', () => { for (let i = 0; i < 1; i++) { expect(loggerSpy).toHaveBeenNthCalledWith( i + 1, - '[scan] Incoming-data: EVM plain address', + '[scan] Incoming-data: ETH chain plain address', ); expect(navigationSpy).toHaveBeenNthCalledWith(i + 1, 'GlobalSelect', { context: 'scanner', @@ -575,6 +581,7 @@ describe('incomingData', () => { network: undefined, // for showing testnet and livenet wallets opts: { showEVMWalletsAndTokens: true, + showSVMWalletsAndTokens: false, feePerKb: undefined, message: '', }, @@ -587,7 +594,7 @@ describe('incomingData', () => { it('Should handle MATIC plain Address', async () => { const data = ['0x0be264522706C703a2c6dDb61488F309a510eA26']; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -598,7 +605,7 @@ describe('incomingData', () => { for (let i = 0; i < 1; i++) { expect(loggerSpy).toHaveBeenNthCalledWith( i + 1, - '[scan] Incoming-data: EVM plain address', + '[scan] Incoming-data: ETH chain plain address', ); expect(navigationSpy).toHaveBeenNthCalledWith(i + 1, 'GlobalSelect', { context: 'scanner', @@ -612,6 +619,7 @@ describe('incomingData', () => { network: undefined, // for showing testnet and livenet wallets opts: { showEVMWalletsAndTokens: true, + showSVMWalletsAndTokens: false, feePerKb: undefined, message: '', }, @@ -624,7 +632,7 @@ describe('incomingData', () => { it('Should handle XRP plain Address', async () => { const data = ['rh3VLyj1GbQjX7eA15BwUagEhSrPHmLkSR']; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -635,7 +643,7 @@ describe('incomingData', () => { for (let i = 0; i < 1; i++) { expect(loggerSpy).toHaveBeenNthCalledWith( i + 1, - '[scan] Incoming-data: xrp plain address', + '[scan] Incoming-data: XRP chain plain address', ); expect(navigationSpy).toHaveBeenNthCalledWith(i + 1, 'GlobalSelect', { context: 'scanner', @@ -649,6 +657,7 @@ describe('incomingData', () => { network: undefined, // for showing testnet and livenet wallets opts: { showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, feePerKb: undefined, message: '', }, @@ -661,7 +670,7 @@ describe('incomingData', () => { it('Should handle DOGECOIN plain Address', async () => { const data = ['DQmgVRe3RJLz6UNoy1hkjuKdYCWCP6VXSW']; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -672,7 +681,7 @@ describe('incomingData', () => { for (let i = 0; i < 1; i++) { expect(loggerSpy).toHaveBeenNthCalledWith( i + 1, - '[scan] Incoming-data: doge plain address', + '[scan] Incoming-data: DOGE chain plain address', ); expect(navigationSpy).toHaveBeenNthCalledWith(i + 1, 'GlobalSelect', { context: 'scanner', @@ -686,6 +695,7 @@ describe('incomingData', () => { network: 'livenet', opts: { showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, feePerKb: undefined, message: '', }, @@ -701,7 +711,7 @@ describe('incomingData', () => { 'ltc1qesyhcljmtnfge44j7kcc0jvqxzcy4r4gz84m9l3etym2kndqwtxsakkxma', ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -712,7 +722,7 @@ describe('incomingData', () => { for (let i = 0; i < 2; i++) { expect(loggerSpy).toHaveBeenNthCalledWith( i + 1, - '[scan] Incoming-data: ltc plain address', + '[scan] Incoming-data: LTC chain plain address', ); expect(navigationSpy).toHaveBeenNthCalledWith(i + 1, 'GlobalSelect', { context: 'scanner', @@ -726,6 +736,7 @@ describe('incomingData', () => { network: 'livenet', opts: { showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, feePerKb: undefined, message: '', }, @@ -748,17 +759,17 @@ describe('incomingData', () => { network: 'livenet', }, { - log: '[scan] Incoming-data: bch plain address', + log: '[scan] Incoming-data: BCH chain plain address', network: 'livenet', }, { - log: '[scan] Incoming-data: bch plain address', + log: '[scan] Incoming-data: BCH chain plain address', network: 'testnet', }, ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -784,6 +795,7 @@ describe('incomingData', () => { network: expected[i].network, // for showing testnet and livenet wallets opts: { showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, feePerKb: undefined, message: '', }, @@ -796,7 +808,7 @@ describe('incomingData', () => { it('Should handle ETH URI as address without amount', async () => { const data = ['ethereum:0xb506c911deE6379e3d4c4d0F4A429a70523960Fd']; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -823,6 +835,7 @@ describe('incomingData', () => { feePerKb: undefined, message: '', showEVMWalletsAndTokens: true, + showSVMWalletsAndTokens: false, }, type: 'address', }, @@ -833,7 +846,7 @@ describe('incomingData', () => { it('Should handle XRP URI as address without amount', async () => { const data = ['ripple:rh3VLyj1GbQjX7eA15BwUagEhSrPHmLkSR']; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -855,6 +868,7 @@ describe('incomingData', () => { destinationTag: undefined, opts: { showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, feePerKb: undefined, message: '', }, @@ -870,7 +884,7 @@ describe('incomingData', () => { 'dogecoin:nVj1YZn1Mx1Q4JaxEXQMFJAmqGBQQsYvRS', ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -892,6 +906,7 @@ describe('incomingData', () => { network: i === 0 ? 'livenet' : 'testnet', opts: { showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, feePerKb: undefined, message: '', }, @@ -907,7 +922,7 @@ describe('incomingData', () => { 'litecoin:tltc1q0hpcxfptshfddxuzpewrm4kp8528y5jk9nc4ur', ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -929,6 +944,7 @@ describe('incomingData', () => { network: i === 0 ? 'livenet' : 'testnet', opts: { showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, feePerKb: undefined, message: '', }, @@ -953,6 +969,8 @@ describe('incomingData', () => { message: '', feePerKb: undefined, showEVMWalletsAndTokens: true, + showSVMWalletsAndTokens: false, + solanaPayOpts: undefined, }, type: 'address', }, @@ -971,6 +989,8 @@ describe('incomingData', () => { feePerKb: 400000000000000, message: '', showEVMWalletsAndTokens: true, + showSVMWalletsAndTokens: false, + solanaPayOpts: undefined, }, type: 'address', }, @@ -979,7 +999,7 @@ describe('incomingData', () => { ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -1010,10 +1030,13 @@ describe('incomingData', () => { recipient: { address: '0xb506c911deE6379e3d4c4d0F4A429a70523960Fd', chain: 'matic', - currency: 'matic', + currency: 'pol', opts: { + feePerKb: undefined, message: '', - showEVMWalletsAndTokens: true, + showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, + solanaPayOpts: undefined, }, type: 'address', }, @@ -1027,11 +1050,13 @@ describe('incomingData', () => { recipient: { address: '0xb506c911deE6379e3d4c4d0F4A429a70523960Fd', chain: 'matic', - currency: 'matic', + currency: 'pol', opts: { feePerKb: 400000000000000, message: '', - showEVMWalletsAndTokens: true, + showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, + solanaPayOpts: undefined, }, type: 'address', }, @@ -1040,7 +1065,7 @@ describe('incomingData', () => { ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -1073,8 +1098,11 @@ describe('incomingData', () => { chain: 'xrp', currency: 'xrp', opts: { + feePerKb: undefined, message: '', showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, + solanaPayOpts: undefined, }, type: 'address', destinationTag: undefined, @@ -1091,8 +1119,11 @@ describe('incomingData', () => { chain: 'xrp', currency: 'xrp', opts: { + feePerKb: undefined, message: '', showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, + solanaPayOpts: undefined, }, type: 'address', destinationTag: 123456, @@ -1101,7 +1132,7 @@ describe('incomingData', () => { }, ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -1142,7 +1173,7 @@ describe('incomingData', () => { ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -1172,6 +1203,8 @@ describe('incomingData', () => { feePerKb: undefined, message: '', showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, + solanaPayOpts: undefined, }, }, }); @@ -1192,7 +1225,7 @@ describe('incomingData', () => { {amount: 1}, ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -1218,8 +1251,11 @@ describe('incomingData', () => { network: 'livenet', type: 'address', opts: { - showEVMWalletsAndTokens: false, + feePerKb: undefined, message, + showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, + solanaPayOpts: undefined, }, }, }); @@ -1229,7 +1265,7 @@ describe('incomingData', () => { it('Should Handle Bitcoin Cash URI with legacy address', async () => { const data = 'bitcoincash:1ML5KKKrJEHw3fQqhhajQjHWkh3yKhNZpa'; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const result = await store.dispatch(incomingData(data)); expect(result).toStrictEqual(true); @@ -1266,6 +1302,7 @@ describe('incomingData', () => { feePerKb: undefined, message: '', showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, }, }, }); @@ -1275,7 +1312,7 @@ describe('incomingData', () => { it.skip('Should Handle Testnet Bitcoin Cash URI with legacy address', async () => { const data = 'bchtest:mu7ns6LXun5rQiyTJx7yY1QxTzndob4bhJ'; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const result = await store.dispatch(incomingData(data)); expect(result).toStrictEqual(true); @@ -1331,7 +1368,7 @@ describe('incomingData', () => { 'bitpay:0x0be264522706C703a2c6dDb61488F309a510eA26?coin=usdc&chain=matic&amount=0.0002&message=asd', ]; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -1366,6 +1403,8 @@ describe('incomingData', () => { } const showEVMWalletsAndTokens = !!BitpaySupportedEvmCoins[coin.toLowerCase()]; + const showSVMWalletsAndTokens = + !!BitpaySupportedSvmCoins[coin.toLowerCase()]; expect(loggerSpy).toHaveBeenNthCalledWith( i + 1, '[scan] Incoming-data: BitPay URI', @@ -1382,8 +1421,10 @@ describe('incomingData', () => { type: 'address', opts: { showEVMWalletsAndTokens, + showSVMWalletsAndTokens, message, feePerKb, + solanaPayOpts: undefined, }, }, }); @@ -1438,7 +1479,7 @@ describe('incomingData', () => { ]; const expected = ['livenet', 'testnet']; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promises: any[] = []; data.forEach(element => @@ -1449,7 +1490,7 @@ describe('incomingData', () => { for (let i = 0; i < 2; i++) { expect(loggerSpy).toHaveBeenNthCalledWith( i + 1, - '[scan] Incoming-data: btc plain address', + '[scan] Incoming-data: BTC chain plain address', ); expect(navigationSpy).toHaveBeenNthCalledWith(i + 1, 'GlobalSelect', { context: 'scanner', @@ -1463,6 +1504,7 @@ describe('incomingData', () => { feePerKb: undefined, message: '', showEVMWalletsAndTokens: false, + showSVMWalletsAndTokens: false, }, }, }); @@ -1472,7 +1514,7 @@ describe('incomingData', () => { it('Should handle private keys and redir to PaperWallet page [testnet]', async () => { const data = 'cQ8jwsnoGCwfgpbaqUnPy5eC11SCgEbK8GybFjn7R81zdUJCMbh3'; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promiseResult = await store.dispatch(incomingData(data)); expect(loggerSpy).toHaveBeenCalledWith('[scan] Incoming-data: private key'); @@ -1485,7 +1527,7 @@ describe('incomingData', () => { it('Should handle private keys and redir to PaperWallet page [livenet]', async () => { const data = 'KxF1dRg147vdwM5v9k74Jz4Qv6Wi1uxQwkWXZ5TwdzspUWz7jA7F'; const store = configureTestStore({}); - const loggerSpy = jest.spyOn(logActions, 'info'); + const loggerSpy = jest.spyOn(logManager, 'info'); const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); const promiseResult = await store.dispatch(incomingData(data)); expect(loggerSpy).toHaveBeenCalledWith('[scan] Incoming-data: private key'); @@ -1494,4 +1536,441 @@ describe('incomingData', () => { scannedPrivateKey: 'KxF1dRg147vdwM5v9k74Jz4Qv6Wi1uxQwkWXZ5TwdzspUWz7jA7F', }); }); + + it('Should return false for unrecognized data', async () => { + const data = 'this is not a valid uri or address'; + const store = configureTestStore({}); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(false); + }); + + it('Should not handle SOL plain address when @solana/kit is mocked (IsValidSVMAddress returns false)', async () => { + // @solana/kit is mocked as {} in tests — the `address()` function is not present. + // SolValidation.validateAddress() therefore throws and returns false. + // So a raw Solana address (44-char base58) is unrecognized by incomingData. + const data = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const store = configureTestStore({}); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(false); + }); + + it('Should handle ARB URI as address without amount', async () => { + // ARB validation uses regex /arbitrum/i, so the URI prefix must be "arbitrum:" + // currency is 'eth' and BitpaySupportedEvmCoins['eth'] is truthy → showEVMWalletsAndTokens: true + const data = ['arbitrum:0xb506c911deE6379e3d4c4d0F4A429a70523960Fd']; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); + const promises: any[] = []; + data.forEach(element => + promises.push(store.dispatch(incomingData(element))), + ); + const promisesResult = await Promise.all(promises); + expect(promisesResult).toStrictEqual([true]); + expect(loggerSpy).toHaveBeenNthCalledWith( + 1, + '[scan] Incoming-data: Arb URI', + ); + expect(navigationSpy).toHaveBeenNthCalledWith(1, 'GlobalSelect', { + context: 'scanner', + recipient: { + address: '0xb506c911deE6379e3d4c4d0F4A429a70523960Fd', + chain: 'arb', + currency: 'eth', + opts: { + showEVMWalletsAndTokens: true, // eth is in BitpaySupportedEvmCoins + showSVMWalletsAndTokens: false, + feePerKb: undefined, + message: '', + }, + type: 'address', + }, + }); + }); + + it('Should handle BASE URI as address without amount', async () => { + // BASE validation uses regex /base/i, so "base:" prefix works + // currency is 'eth' and BitpaySupportedEvmCoins['eth'] is truthy → showEVMWalletsAndTokens: true + const data = ['base:0xb506c911deE6379e3d4c4d0F4A429a70523960Fd']; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); + const promises: any[] = []; + data.forEach(element => + promises.push(store.dispatch(incomingData(element))), + ); + const promisesResult = await Promise.all(promises); + expect(promisesResult).toStrictEqual([true]); + expect(loggerSpy).toHaveBeenNthCalledWith( + 1, + '[scan] Incoming-data: Base URI', + ); + expect(navigationSpy).toHaveBeenNthCalledWith(1, 'GlobalSelect', { + context: 'scanner', + recipient: { + address: '0xb506c911deE6379e3d4c4d0F4A429a70523960Fd', + chain: 'base', + currency: 'eth', + opts: { + showEVMWalletsAndTokens: true, // eth is in BitpaySupportedEvmCoins + showSVMWalletsAndTokens: false, + feePerKb: undefined, + message: '', + }, + type: 'address', + }, + }); + }); + + it('Should handle OP URI as address without amount', async () => { + // OP validation uses regex /optimism/i, so "optimism:" prefix is needed + // currency is 'eth' and BitpaySupportedEvmCoins['eth'] is truthy → showEVMWalletsAndTokens: true + const data = ['optimism:0xb506c911deE6379e3d4c4d0F4A429a70523960Fd']; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationSpy = jest.spyOn(Root.navigationRef, 'navigate'); + const promises: any[] = []; + data.forEach(element => + promises.push(store.dispatch(incomingData(element))), + ); + const promisesResult = await Promise.all(promises); + expect(promisesResult).toStrictEqual([true]); + expect(loggerSpy).toHaveBeenNthCalledWith( + 1, + '[scan] Incoming-data: Op URI', + ); + expect(navigationSpy).toHaveBeenNthCalledWith(1, 'GlobalSelect', { + context: 'scanner', + recipient: { + address: '0xb506c911deE6379e3d4c4d0F4A429a70523960Fd', + chain: 'op', + currency: 'eth', + opts: { + showEVMWalletsAndTokens: true, // eth is in BitpaySupportedEvmCoins + showSVMWalletsAndTokens: false, + feePerKb: undefined, + message: '', + }, + type: 'address', + }, + }); + }); + + it('Should handle SolanaPay URI (solana: prefix intercepts IsValidSolUri)', async () => { + // solana: URIs always match IsValidSolanaPay when the address is valid (PublicKey mock never throws), + // so they are dispatched to handleSolanaPay, not handleSolUri + const data = ['solana:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v']; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const promises: any[] = []; + data.forEach(element => + promises.push(store.dispatch(incomingData(element))), + ); + const promisesResult = await Promise.all(promises); + expect(promisesResult).toStrictEqual([true]); + expect(loggerSpy).toHaveBeenNthCalledWith( + 1, + '[scan] Incoming-data: SolanaPay URI', + ); + }); + + it('Should handle buyCrypto URI and reset navigation', async () => { + const data = 'bitpay://buy?coin=btc&chain=btc&amount=100'; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Incoming-data (redirect): Buy crypto pre-set'), + ); + expect(navigationResetSpy).toHaveBeenCalled(); + }); + + it('Should handle sellCrypto URI and reset navigation', async () => { + const data = 'bitpay://sell?coin=btc&chain=btc&amount=100'; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Incoming-data (redirect): Sell crypto pre-set'), + ); + expect(navigationResetSpy).toHaveBeenCalled(); + }); + + it('Should handle swapCrypto URI and reset navigation', async () => { + const data = 'bitpay://swap?partner=thorswap'; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Incoming-data (redirect): Swap crypto pre-set'), + ); + expect(navigationResetSpy).toHaveBeenCalled(); + }); + + it('Should handle banxa URI that is cancelled — early return without navigation', async () => { + const data = 'bitpay://banxaCancelled?something=1'; + const store = configureTestStore({}); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(navigationResetSpy).not.toHaveBeenCalled(); + }); + + it('Should handle banxa URI with no externalId — logs warn and returns early', async () => { + const data = 'bitpay://banxa?someOtherParam=123'; + const store = configureTestStore({}); + const loggerWarnSpy = jest.spyOn(logManager, 'warn'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'No banxaExternalId present. Do not redir', + ); + expect(navigationResetSpy).not.toHaveBeenCalled(); + }); + + it('Should handle banxa URI with externalId and reset navigation', async () => { + const data = 'bitpay://banxa?externalId=abc123&orderStatus=pending'; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Incoming-data (redirect): Banxa URL'), + ); + expect(navigationResetSpy).toHaveBeenCalled(); + }); + + it('Should handle moonpay URI with no externalId — logs warn and returns early', async () => { + const data = 'bitpay://moonpay?someOtherParam=123'; + const store = configureTestStore({}); + const loggerWarnSpy = jest.spyOn(logManager, 'warn'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'No externalId present. Do not redir', + ); + expect(navigationResetSpy).not.toHaveBeenCalled(); + }); + + it('Should handle moonpay buy URI with externalId and reset navigation', async () => { + const data = + 'bitpay://moonpay?externalId=ext123&transactionId=tx456&transactionStatus=completed'; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Incoming-data (redirect): Moonpay URL'), + ); + expect(navigationResetSpy).toHaveBeenCalled(); + }); + + it('Should handle ramp URI with no rampExternalId — logs warn and returns early', async () => { + const data = 'bitpay://ramp?someOtherParam=123'; + const store = configureTestStore({}); + const loggerWarnSpy = jest.spyOn(logManager, 'warn'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'No rampExternalId present. Do not redir', + ); + expect(navigationResetSpy).not.toHaveBeenCalled(); + }); + + it('Should handle ramp URI with rampExternalId and reset navigation', async () => { + const data = + 'bitpay://ramp?rampExternalId=ramp123&walletId=w1&status=success'; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Incoming-data (redirect): Ramp URL'), + ); + expect(navigationResetSpy).toHaveBeenCalled(); + }); + + it('Should handle sardine URI with no sardineExternalId — logs warn and returns early', async () => { + const data = 'bitpay://sardine?someOtherParam=123'; + const store = configureTestStore({}); + const loggerWarnSpy = jest.spyOn(logManager, 'warn'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'No sardineExternalId present. Do not redir', + ); + expect(navigationResetSpy).not.toHaveBeenCalled(); + }); + + it('Should handle simplex URI with no paymentId (buy flow) — logs warn and returns early', async () => { + const data = 'bitpay://simplex?someOtherParam=123'; + const store = configureTestStore({}); + const loggerWarnSpy = jest.spyOn(logManager, 'warn'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'No paymentId present. Do not redir', + ); + expect(navigationResetSpy).not.toHaveBeenCalled(); + }); + + it('Should handle simplex buy URI with paymentId and reset navigation', async () => { + const data = + 'bitpay://simplex?paymentId=pay123"eId=q456&userId=u789&success=true'; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Incoming-data (redirect): Simplex URL'), + ); + expect(navigationResetSpy).toHaveBeenCalled(); + }); + + it('Should handle transak URI with no transakExternalId — logs warn and returns early', async () => { + const data = 'bitpay://transak?someOtherParam=123'; + const store = configureTestStore({}); + const loggerWarnSpy = jest.spyOn(logManager, 'warn'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'No transakExternalId present. Do not redir', + ); + expect(navigationResetSpy).not.toHaveBeenCalled(); + }); + + it('Should handle transak URI with transakExternalId and reset navigation', async () => { + const data = + 'bitpay://transak?partnerOrderId=ord123&orderId=t456&status=completed'; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Incoming-data (redirect): Transak URL'), + ); + expect(navigationResetSpy).toHaveBeenCalled(); + }); + + it('Should handle AddKey path URI and reset navigation', async () => { + const data = 'bitpay://addKey'; + const store = configureTestStore({}); + const loggerSpy = jest.spyOn(logManager, 'info'); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + '[scan] Incoming-data: Go to Add key path', + data, + ); + expect(navigationResetSpy).toHaveBeenCalled(); + }); + + it('Should handle buyCrypto URI with non-UTXO coin (no chain) — coin becomes undefined', async () => { + // eth is not a UTXO chain, so without chain param the coin should be dropped + const data = 'bitpay://buy?coin=eth&amount=50'; + const store = configureTestStore({}); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + const resetCall = navigationResetSpy.mock.calls[0][0] as any; + const params = resetCall?.routes?.[1]?.params; + expect(params?.currencyAbbreviation).toBeUndefined(); + }); + + it('Should handle sellCrypto URI with UTXO coin but no chain — chain becomes coin', async () => { + const data = 'bitpay://sell?coin=btc&amount=50'; + const store = configureTestStore({}); + const navigationResetSpy = jest.spyOn(Root.navigationRef, 'reset'); + const promiseResult = await store.dispatch(incomingData(data)); + expect(promiseResult).toBe(true); + const resetCall = navigationResetSpy.mock.calls[0][0] as any; + const params = resetCall?.routes?.[1]?.params; + expect(params?.chain).toBe('btc'); + }); +}); + +describe('setBuyerProvidedEmail', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Should resolve and navigate to PayPro when status is success', async () => { + const invoiceUrl = 'https://bitpay.com/i/TestInvoiceId123'; + const email = 'test@example.com'; + + const mockPayProOptions: PayPro.PayProOptions = { + paymentId: '10', + time: '10', + expires: '2019-11-05T16:29:31.754Z', + memo: 'test memo', + payProUrl: 'https://bitpay.com/i/TestInvoiceId123', + paymentOptions: [], + verified: true, + }; + + const store = configureTestStore({}); + + (axios.post as jest.Mock).mockResolvedValue({ + data: {status: 'success'}, + }); + + const payproSpy = jest.spyOn(PayPro, 'GetPayProOptions'); + ( + payproSpy as jest.MockedFunction + ).mockImplementation(() => () => Promise.resolve(mockPayProOptions)); + + (axios.get as jest.Mock).mockResolvedValue({ + data: {data: {}}, + }); + + await expect( + store.dispatch(setBuyerProvidedEmail(invoiceUrl, email)), + ).resolves.toBeUndefined(); + }); + + it('Should reject when status is not success', async () => { + const invoiceUrl = 'https://bitpay.com/i/TestInvoiceId123'; + const email = 'test@example.com'; + + const store = configureTestStore({}); + + (axios.post as jest.Mock).mockResolvedValue({ + data: {status: 'error'}, + }); + + await expect( + store.dispatch(setBuyerProvidedEmail(invoiceUrl, email)), + ).rejects.toBeUndefined(); + }); + + it('Should reject when axios.post throws', async () => { + const invoiceUrl = 'https://bitpay.com/i/TestInvoiceId123'; + const email = 'test@example.com'; + + const store = configureTestStore({}); + + (axios.post as jest.Mock).mockRejectedValue(new Error('network error')); + + await expect( + store.dispatch(setBuyerProvidedEmail(invoiceUrl, email)), + ).rejects.toBeUndefined(); + }); }); diff --git a/src/store/transforms/transforms.spec.ts b/src/store/transforms/transforms.spec.ts new file mode 100644 index 0000000000..a377ff5a71 --- /dev/null +++ b/src/store/transforms/transforms.spec.ts @@ -0,0 +1,822 @@ +/** + * Tests for src/store/transforms/transforms.ts + * + * Strategy: + * - Test the pure/utility logic exposed via the exported createTransform + * wrappers by invoking the inbound/outbound transform callbacks directly. + * - Heavy native deps (BwcProvider, Sentry, logManager) are mocked so the + * module loads cleanly in Jest. + */ + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('../../lib/bwc', () => { + const mockWalletClient = { + credentials: {}, + }; + const mockInstance = { + getClient: jest.fn(() => mockWalletClient), + createKey: jest.fn(() => ({ + id: 'mock-key-id', + isPrivKeyEncrypted: jest.fn(() => false), + toObj: jest.fn(() => ({})), + })), + createTssKey: jest.fn(() => ({ + id: 'mock-tss-key-id', + isPrivKeyEncrypted: jest.fn(() => false), + toObj: jest.fn(() => ({})), + })), + getErrors: jest.fn(() => ({})), + getUtils: jest.fn(() => ({formatAmount: jest.fn(() => '0')})), + getBitcore: jest.fn(() => ({})), + getBitcoreCash: jest.fn(() => ({})), + getBitcoreDoge: jest.fn(() => ({})), + getBitcoreLtc: jest.fn(() => ({})), + getCore: jest.fn(() => ({})), + getPayProV2: jest.fn(() => ({trustedKeys: {}})), + getTssSign: jest.fn(() => class MockTssSign {}), + getTssKey: jest.fn(() => class MockTssKey {}), + getConstants: jest.fn(() => ({ + SCRIPT_TYPES: {}, + DERIVATION_STRATEGIES: {}, + })), + getEncryption: jest.fn(() => ({})), + getLogger: jest.fn(() => ({})), + parseSecret: jest.fn(), + }; + return { + BwcProvider: { + getInstance: jest.fn(() => mockInstance), + API: {}, + instance: mockInstance, + }, + }; +}); + +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), + init: jest.fn(), +})); + +jest.mock('../../managers/LogManager', () => ({ + logManager: {info: jest.fn(), error: jest.fn(), debug: jest.fn()}, +})); + +jest.mock('../log', () => ({ + LogActions: { + persistLog: jest.fn((x: any) => x), + error: jest.fn((msg: string) => ({type: 'LOG_ERROR', payload: msg})), + info: jest.fn((msg: string) => ({type: 'LOG_INFO', payload: msg})), + }, +})); + +jest.mock('../log/initLogs', () => ({ + add: jest.fn(), +})); + +jest.mock('../wallet/utils/wallet', () => ({ + buildWalletObj: jest.fn(() => ({id: 'mock-wallet'})), +})); + +jest.mock('./encrypt', () => ({ + encryptAppStore: jest.fn((s: any) => s), + decryptAppStore: jest.fn((s: any) => s), + encryptShopStore: jest.fn((s: any) => s), + decryptShopStore: jest.fn((s: any) => s), + encryptWalletStore: jest.fn((s: any) => s), + decryptWalletStore: jest.fn((s: any) => s), +})); + +jest.mock('redux-persist', () => ({ + createTransform: jest.fn((inFn: any, outFn: any, config: any) => ({ + in: inFn, + out: outFn, + config, + })), +})); + +jest.mock('../../utils/portfolio/core/pnl/snapshotSeries', () => ({ + packBalanceSnapshotsToSeries: jest.fn((opts: any) => ({ + _packed: true, + snapshots: opts.snapshots, + })), + hydrateBalanceSnapshotsFromSeries: jest.fn( + (series: any) => series.snapshots || [], + ), + isBalanceSnapshotSeries: jest.fn((value: any) => value?._packed === true), +})); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { + bootstrapKey, + bootstrapWallets, + bindWalletKeys, + transformContacts, + transformPortfolioPopulateStatus, + transformPortfolioSnapshotSeries, + encryptSpecificFields, +} from './transforms'; + +import { + encryptWalletStore, + decryptWalletStore, + encryptAppStore, + decryptAppStore, + encryptShopStore, + decryptShopStore, +} from './encrypt'; + +import { + packBalanceSnapshotsToSeries, + hydrateBalanceSnapshotsFromSeries, + isBalanceSnapshotSeries, +} from '../../utils/portfolio/core/pnl/snapshotSeries'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const makeWallet = (overrides: any = {}): any => ({ + id: 'wallet-1', + credentials: { + walletId: 'wallet-1', + keyId: 'key-1', + n: 1, + m: 1, + isComplete: () => true, + }, + balance: { + sat: 0, + satLocked: 0, + satConfirmedLocked: 0, + satSpendable: 0, + satPending: 0, + crypto: '0', + cryptoLocked: '0', + cryptoConfirmedLocked: '0', + cryptoSpendable: '0', + cryptoPending: '0', + fiat: 0, + fiatLastDay: 0, + fiatLocked: 0, + fiatConfirmedLocked: 0, + fiatSpendable: 0, + fiatPending: 0, + }, + transactionHistory: { + transactions: [], + loadMore: true, + hasConfirmingTxs: false, + }, + network: 'mainnet', + keyId: 'key-1', + pendingTxps: [], + ...overrides, +}); + +const makeKey = (overrides: any = {}): any => ({ + id: 'key-1', + wallets: [], + properties: {metadata: true, fingerPrint: 'fp-1'}, + methods: { + isPrivKeyEncrypted: jest.fn(() => false), + toObj: jest.fn(() => ({})), + }, + totalBalance: 0, + totalBalanceLastDay: 0, + backupComplete: false, + keyName: 'My Key', + hideKeyBalance: false, + isReadOnly: false, + ...overrides, +}); + +// ─── bootstrapKey ───────────────────────────────────────────────────────────── + +describe('bootstrapKey', () => { + it('returns key unchanged when id is "readonly"', () => { + const key = makeKey(); + const result = bootstrapKey(key, 'readonly'); + expect(result).toBe(key); + }); + + it('returns key unchanged when key has hardwareSource', () => { + const key = makeKey({hardwareSource: 'ledger'}); + const result = bootstrapKey(key, 'some-id'); + expect(result).toBe(key); + }); + + it('calls createTssKey when key has properties.metadata', () => { + const {BwcProvider} = require('../../lib/bwc'); + const instance = BwcProvider.getInstance(); + const key = makeKey({ + properties: {metadata: {id: 'meta', n: 2, m: 1}, keychain: {}}, + }); + bootstrapKey(key, 'my-tss-key'); + expect(instance.createTssKey).toHaveBeenCalled(); + }); + + it('restores privateKeyShare Buffer when data is present', () => { + const {BwcProvider} = require('../../lib/bwc'); + const instance = BwcProvider.getInstance(); + const key = makeKey({ + properties: { + metadata: {id: 'meta', n: 2, m: 1}, + keychain: {privateKeyShare: {data: [1, 2, 3]}}, + }, + }); + bootstrapKey(key, 'my-tss-key'); + expect(instance.createTssKey).toHaveBeenCalled(); + }); + + it('restores reducedPrivateKeyShare Buffer when data is present', () => { + const {BwcProvider} = require('../../lib/bwc'); + const instance = BwcProvider.getInstance(); + const key = makeKey({ + properties: { + metadata: {id: 'meta', n: 2, m: 1}, + keychain: { + privateKeyShare: {data: [1]}, + reducedPrivateKeyShare: {data: [4, 5]}, + }, + }, + }); + bootstrapKey(key, 'my-tss-key'); + expect(instance.createTssKey).toHaveBeenCalled(); + }); + + it('calls createKey when key has no metadata (standard key)', () => { + const {BwcProvider} = require('../../lib/bwc'); + const instance = BwcProvider.getInstance(); + const key = makeKey({properties: {someData: 'x'}}); // no .metadata + bootstrapKey(key, 'my-standard-key'); + expect(instance.createKey).toHaveBeenCalledWith({ + seedType: 'object', + seedData: key.properties, + }); + }); + + it('returns undefined (swallows error) when createKey throws', () => { + const {BwcProvider} = require('../../lib/bwc'); + const instance = BwcProvider.getInstance(); + instance.createKey.mockImplementationOnce(() => { + throw new Error('createKey failed'); + }); + const key = makeKey({properties: {someData: 'x'}}); + const result = bootstrapKey(key, 'bad-key'); + expect(result).toBeUndefined(); + }); + + it('returns undefined (swallows error) when createTssKey throws', () => { + const {BwcProvider} = require('../../lib/bwc'); + const instance = BwcProvider.getInstance(); + instance.createTssKey.mockImplementationOnce(() => { + throw new Error('tss failed'); + }); + const key = makeKey({ + properties: {metadata: {id: 'meta', n: 2, m: 1}, keychain: {}}, + }); + const result = bootstrapKey(key, 'bad-tss-key'); + expect(result).toBeUndefined(); + }); +}); + +// ─── bootstrapWallets ───────────────────────────────────────────────────────── + +describe('bootstrapWallets', () => { + it('returns an array with bootstrapped wallets', () => { + const wallet = makeWallet(); + const result = bootstrapWallets([wallet]); + expect(Array.isArray(result)).toBe(true); + }); + + it('filters out wallets that threw during bootstrapping', () => { + const {BwcProvider} = require('../../lib/bwc'); + const instance = BwcProvider.getInstance(); + instance.getClient.mockImplementationOnce(() => { + throw new Error('bad wallet'); + }); + const badWallet = makeWallet({id: 'bad', credentials: {walletId: 'bad'}}); + const result = bootstrapWallets([badWallet]); + expect(result).toHaveLength(0); + }); + + it('resets transactionHistory for each wallet', () => { + const wallet = makeWallet({ + transactionHistory: { + transactions: [{id: 'tx1'}], + loadMore: false, + hasConfirmingTxs: true, + }, + }); + bootstrapWallets([wallet]); + expect(wallet.transactionHistory).toEqual({ + transactions: [], + loadMore: true, + hasConfirmingTxs: false, + }); + }); + + it('returns empty array when given empty array', () => { + const result = bootstrapWallets([]); + expect(result).toEqual([]); + }); +}); + +// ─── bindWalletKeys transform ───────────────────────────────────────────────── + +describe('bindWalletKeys', () => { + const getInbound = () => (bindWalletKeys as any).in; + const getOutbound = () => (bindWalletKeys as any).out; + + it('inbound: returns state unchanged when no keys', () => { + const state: any = {keys: {}}; + const result = getInbound()(state); + expect(result).toBe(state); + }); + + it('inbound: strips transactionHistory from wallets', () => { + const wallet = makeWallet(); + wallet.transactionHistory = { + transactions: [{id: 'tx'}], + loadMore: false, + hasConfirmingTxs: false, + }; + const state: any = { + keys: { + 'key-1': {wallets: [wallet]}, + }, + }; + getInbound()(state); + expect(wallet.transactionHistory).toBeUndefined(); + }); + + it('outbound: returns state unchanged when no keys', () => { + const state: any = {keys: {}}; + const result = getOutbound()(state); + expect(result).toBe(state); + }); + + it('outbound: bootstraps wallets for each key', () => { + const wallet = makeWallet(); + const key = makeKey({wallets: [wallet]}); + const state: any = {keys: {'key-1': key}}; + const result = getOutbound()(state); + expect(Array.isArray(result.keys['key-1'].wallets)).toBe(true); + }); +}); + +// ─── transformContacts ──────────────────────────────────────────────────────── + +describe('transformContacts', () => { + const getOutbound = () => (transformContacts as any).out; + + it('passes through inbound state unchanged', () => { + const state: any = {list: []}; + const result = (transformContacts as any).in(state); + expect(result).toBe(state); + }); + + it('returns state unchanged when list is empty', () => { + const state: any = {list: []}; + const result = getOutbound()(state); + expect(result.list).toEqual([]); + }); + + it('adds chain equal to coin for known UTXO coins', () => { + const state: any = { + list: [{coin: 'btc', chain: undefined, name: 'Test BTC'}], + }; + const result = getOutbound()(state); + expect(result.list[0].chain).toBe('btc'); + }); + + it('adds chain equal to coin for known OtherBitpay coins', () => { + const state: any = { + list: [{coin: 'eth', chain: undefined, name: 'Test ETH'}], + }; + const result = getOutbound()(state); + expect(result.list[0].chain).toBe('eth'); + }); + + it('defaults chain to "eth" for unknown coins', () => { + const state: any = { + list: [{coin: 'unknown_coin_xyz', chain: undefined, name: 'Unknown'}], + }; + const result = getOutbound()(state); + expect(result.list[0].chain).toBe('eth'); + }); + + it('preserves existing chain value', () => { + const state: any = { + list: [{coin: 'eth', chain: 'matic', name: 'Test'}], + }; + const result = getOutbound()(state); + expect(result.list[0].chain).toBe('matic'); + }); + + it('returns outboundState on error', () => { + // list is not iterable — should catch and return original state + const state: any = {list: null}; + // Accessing .length on null will throw inside the transform + // Force the error path by making list non-array in a way that throws + Object.defineProperty(state, 'list', { + get: () => { + throw new Error('forced error'); + }, + configurable: true, + }); + // The function's catch block should return the (broken) outboundState + expect(() => getOutbound()(state)).not.toThrow(); + }); +}); + +// ─── transformPortfolioPopulateStatus ───────────────────────────────────────── + +describe('transformPortfolioPopulateStatus', () => { + const getOutbound = () => (transformPortfolioPopulateStatus as any).out; + + it('passes through inbound state unchanged', () => { + const state: any = {}; + const result = (transformPortfolioPopulateStatus as any).in(state); + expect(result).toBe(state); + }); + + it('sets inProgress to false when it was true', () => { + const state: any = { + populateStatus: {inProgress: true, currentWalletId: 'w1'}, + }; + const result = getOutbound()(state); + expect(result.populateStatus.inProgress).toBe(false); + expect(result.populateStatus.currentWalletId).toBeUndefined(); + }); + + it('returns state unchanged when inProgress is false', () => { + const state: any = { + populateStatus: {inProgress: false, currentWalletId: undefined}, + }; + const result = getOutbound()(state); + expect(result).toBe(state); + }); + + it('returns state unchanged when populateStatus is missing', () => { + const state: any = {}; + const result = getOutbound()(state); + expect(result).toBe(state); + }); +}); + +// ─── transformPortfolioSnapshotSeries ───────────────────────────────────────── + +describe('transformPortfolioSnapshotSeries', () => { + const getInbound = () => (transformPortfolioSnapshotSeries as any).in; + const getOutbound = () => (transformPortfolioSnapshotSeries as any).out; + + const makeSnapshot = (overrides: any = {}): any => ({ + id: 'snap-1', + walletId: 'wallet-1', + chain: 'eth', + coin: 'eth', + network: 'mainnet', + assetId: '', + timestamp: Date.now(), + eventType: 'daily', + txIds: undefined, + cryptoBalance: '1.0', + balanceDeltaAtomic: undefined, + remainingCostBasisFiat: 100, + quoteCurrency: 'USD', + markRate: 2000, + costBasisRateFiat: 2000, + createdAt: Date.now(), + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + (packBalanceSnapshotsToSeries as jest.Mock).mockImplementation( + (opts: any) => ({ + _packed: true, + snapshots: opts.snapshots, + }), + ); + (isBalanceSnapshotSeries as jest.Mock).mockImplementation( + (v: any) => v?._packed === true, + ); + (hydrateBalanceSnapshotsFromSeries as jest.Mock).mockImplementation( + (series: any) => series.snapshots || [], + ); + }); + + it('inbound: returns state unchanged when snapshotsByWalletId is empty', () => { + const state: any = {snapshotsByWalletId: {}}; + const result = getInbound()(state); + expect(result.snapshotsByWalletId).toEqual({}); + }); + + it('inbound: skips wallet entry when snaps array is empty', () => { + const state: any = {snapshotsByWalletId: {w1: []}}; + const result = getInbound()(state); + expect(result.snapshotsByWalletId['w1']).toBeUndefined(); + }); + + it('inbound: skips wallet entry when value is not array', () => { + const state: any = {snapshotsByWalletId: {w1: 'not-an-array'}}; + const result = getInbound()(state); + expect(result.snapshotsByWalletId['w1']).toBeUndefined(); + }); + + it('inbound: packs snapshots into series', () => { + const snap = makeSnapshot(); + const state: any = {snapshotsByWalletId: {'wallet-1': [snap]}}; + const result = getInbound()(state); + expect(packBalanceSnapshotsToSeries).toHaveBeenCalled(); + expect(result.snapshotsByWalletId['wallet-1']).toBeDefined(); + }); + + it('inbound: handles tx eventType snapshots (compressionEnabled false)', () => { + const snap = makeSnapshot({eventType: 'tx'}); + const state: any = {snapshotsByWalletId: {'wallet-1': [snap]}}; + getInbound()(state); + expect(packBalanceSnapshotsToSeries).toHaveBeenCalledWith( + expect.objectContaining({compressionEnabled: false}), + ); + }); + + it('inbound: omits packed entry when packBalanceSnapshotsToSeries returns falsy', () => { + (packBalanceSnapshotsToSeries as jest.Mock).mockReturnValueOnce(null); + const snap = makeSnapshot(); + const state: any = {snapshotsByWalletId: {'wallet-1': [snap]}}; + const result = getInbound()(state); + expect(result.snapshotsByWalletId['wallet-1']).toBeUndefined(); + }); + + it('inbound: sorts out-of-order snapshots chronologically', () => { + const snap1 = makeSnapshot({timestamp: 2000, id: 'late'}); + const snap2 = makeSnapshot({timestamp: 1000, id: 'early'}); + const state: any = {snapshotsByWalletId: {'wallet-1': [snap1, snap2]}}; + getInbound()(state); + // packBalanceSnapshotsToSeries receives snapshots in sorted order + const callArgs = (packBalanceSnapshotsToSeries as jest.Mock).mock + .calls[0][0]; + expect(callArgs.snapshots[0].timestamp).toBe(1000); + expect(callArgs.snapshots[1].timestamp).toBe(2000); + }); + + it('inbound: snapshot with txIds array maps txIds correctly', () => { + const snap = makeSnapshot({txIds: ['tx1', 'tx2']}); + const state: any = {snapshotsByWalletId: {'wallet-1': [snap]}}; + getInbound()(state); + const callArgs = (packBalanceSnapshotsToSeries as jest.Mock).mock + .calls[0][0]; + expect(callArgs.snapshots[0].txIds).toEqual(['tx1', 'tx2']); + }); + + it('inbound: snapshot without txIds keeps txIds undefined', () => { + const snap = makeSnapshot({txIds: undefined}); + const state: any = {snapshotsByWalletId: {'wallet-1': [snap]}}; + getInbound()(state); + const callArgs = (packBalanceSnapshotsToSeries as jest.Mock).mock + .calls[0][0]; + expect(callArgs.snapshots[0].txIds).toBeUndefined(); + }); + + it('inbound: returns state on error', () => { + const broken: any = { + get snapshotsByWalletId() { + throw new Error('forced'); + }, + }; + expect(() => getInbound()(broken)).not.toThrow(); + }); + + it('outbound: unpacks series into BalanceSnapshot array', () => { + const packedSeries = {_packed: true, snapshots: [makeSnapshot()]}; + const state: any = {snapshotsByWalletId: {'wallet-1': packedSeries}}; + const result = getOutbound()(state); + expect(Array.isArray(result.snapshotsByWalletId['wallet-1'])).toBe(true); + }); + + it('outbound: passes through raw array when not a series', () => { + (isBalanceSnapshotSeries as jest.Mock).mockReturnValueOnce(false); + const rawSnaps = [makeSnapshot()]; + const state: any = {snapshotsByWalletId: {'wallet-1': rawSnaps}}; + const result = getOutbound()(state); + expect(result.snapshotsByWalletId['wallet-1']).toBe(rawSnaps); + }); + + it('outbound: computes fiatBalance as units * markRate', () => { + const snap = makeSnapshot({ + cryptoBalance: '2', + markRate: 1000, + costBasisRateFiat: undefined, + remainingCostBasisFiat: 0, + }); + (hydrateBalanceSnapshotsFromSeries as jest.Mock).mockReturnValueOnce([ + snap, + ]); + const packedSeries = {_packed: true, snapshots: [snap]}; + const state: any = {snapshotsByWalletId: {'wallet-1': packedSeries}}; + const result = getOutbound()(state); + const out = result.snapshotsByWalletId['wallet-1'][0]; + // cryptoBalance is string '2', toFiniteNumber gives 2, markRate 1000 → fiatBalance = 2000 + expect(out.costBasisRateFiat).toBe(1000); + }); + + it('outbound: dayStartMs is set for daily eventType', () => { + const ts = new Date('2024-01-15').getTime(); + const snap = makeSnapshot({eventType: 'daily', timestamp: ts}); + (hydrateBalanceSnapshotsFromSeries as jest.Mock).mockReturnValueOnce([ + snap, + ]); + const packedSeries = {_packed: true, snapshots: [snap]}; + const state: any = {snapshotsByWalletId: {'wallet-1': packedSeries}}; + const result = getOutbound()(state); + expect(result.snapshotsByWalletId['wallet-1'][0].dayStartMs).toBeDefined(); + }); + + it('outbound: dayStartMs is undefined for tx eventType', () => { + const snap = makeSnapshot({eventType: 'tx'}); + (hydrateBalanceSnapshotsFromSeries as jest.Mock).mockReturnValueOnce([ + snap, + ]); + const packedSeries = {_packed: true, snapshots: [snap]}; + const state: any = {snapshotsByWalletId: {'wallet-1': packedSeries}}; + const result = getOutbound()(state); + expect( + result.snapshotsByWalletId['wallet-1'][0].dayStartMs, + ).toBeUndefined(); + }); + + it('outbound: txIds with >1 element is preserved', () => { + const snap = makeSnapshot({txIds: ['a', 'b']}); + (hydrateBalanceSnapshotsFromSeries as jest.Mock).mockReturnValueOnce([ + snap, + ]); + const packedSeries = {_packed: true, snapshots: [snap]}; + const state: any = {snapshotsByWalletId: {'wallet-1': packedSeries}}; + const result = getOutbound()(state); + expect(result.snapshotsByWalletId['wallet-1'][0].txIds).toEqual(['a', 'b']); + }); + + it('outbound: txIds with <=1 element is set to undefined', () => { + const snap = makeSnapshot({txIds: ['only-one']}); + (hydrateBalanceSnapshotsFromSeries as jest.Mock).mockReturnValueOnce([ + snap, + ]); + const packedSeries = {_packed: true, snapshots: [snap]}; + const state: any = {snapshotsByWalletId: {'wallet-1': packedSeries}}; + const result = getOutbound()(state); + expect(result.snapshotsByWalletId['wallet-1'][0].txIds).toBeUndefined(); + }); + + it('outbound: avgCostFiatPerUnit is 0 when units is 0', () => { + const snap = makeSnapshot({ + cryptoBalance: '0', + markRate: 1000, + remainingCostBasisFiat: 50, + }); + (hydrateBalanceSnapshotsFromSeries as jest.Mock).mockReturnValueOnce([ + snap, + ]); + const packedSeries = {_packed: true, snapshots: [snap]}; + const state: any = {snapshotsByWalletId: {'wallet-1': packedSeries}}; + const result = getOutbound()(state); + expect(result.snapshotsByWalletId['wallet-1'][0].avgCostFiatPerUnit).toBe( + 0, + ); + }); + + it('outbound: returns outboundState on error', () => { + const broken: any = { + get snapshotsByWalletId() { + throw new Error('forced'); + }, + }; + expect(() => getOutbound()(broken)).not.toThrow(); + }); +}); + +// ─── encryptSpecificFields ──────────────────────────────────────────────────── + +describe('encryptSpecificFields', () => { + const secretKey = 'test-secret'; + + beforeEach(() => { + jest.clearAllMocks(); + (encryptWalletStore as jest.Mock).mockImplementation((s: any) => ({ + ...s, + _encrypted: true, + })); + (decryptWalletStore as jest.Mock).mockImplementation((s: any) => ({ + ...s, + _decrypted: true, + })); + (encryptAppStore as jest.Mock).mockImplementation((s: any) => ({ + ...s, + _encrypted: true, + })); + (decryptAppStore as jest.Mock).mockImplementation((s: any) => ({ + ...s, + _decrypted: true, + })); + (encryptShopStore as jest.Mock).mockImplementation((s: any) => ({ + ...s, + _encrypted: true, + })); + (decryptShopStore as jest.Mock).mockImplementation((s: any) => ({ + ...s, + _decrypted: true, + })); + }); + + const getTransform = () => { + // encryptSpecificFields uses createTransform directly, so extract in/out fns + const {createTransform} = require('redux-persist'); + let capturedIn: any; + let capturedOut: any; + (createTransform as jest.Mock).mockImplementationOnce( + (inFn: any, outFn: any) => { + capturedIn = inFn; + capturedOut = outFn; + return {in: inFn, out: outFn}; + }, + ); + encryptSpecificFields(secretKey); + return {inFn: capturedIn, outFn: capturedOut}; + }; + + it('encrypts WALLET store on inbound', () => { + const {inFn} = getTransform(); + const state: any = {keys: {}}; + inFn(state, 'WALLET'); + expect(encryptWalletStore).toHaveBeenCalledWith(state, secretKey); + }); + + it('encrypts APP store on inbound', () => { + const {inFn} = getTransform(); + const state: any = {}; + inFn(state, 'APP'); + expect(encryptAppStore).toHaveBeenCalledWith(state, secretKey); + }); + + it('encrypts SHOP store on inbound', () => { + const {inFn} = getTransform(); + const state: any = {}; + inFn(state, 'SHOP'); + expect(encryptShopStore).toHaveBeenCalledWith(state, secretKey); + }); + + it('returns state unchanged for unrecognised key on inbound', () => { + const {inFn} = getTransform(); + const state: any = {foo: 'bar'}; + const result = inFn(state, 'RATE'); + expect(result).toBe(state); + }); + + it('decrypts WALLET store on outbound', () => { + const {outFn} = getTransform(); + const state: any = {keys: {}}; + outFn(state, 'WALLET'); + expect(decryptWalletStore).toHaveBeenCalledWith(state, secretKey); + }); + + it('decrypts APP store on outbound', () => { + const {outFn} = getTransform(); + const state: any = {}; + outFn(state, 'APP'); + expect(decryptAppStore).toHaveBeenCalledWith(state, secretKey); + }); + + it('decrypts SHOP store on outbound', () => { + const {outFn} = getTransform(); + const state: any = {}; + outFn(state, 'SHOP'); + expect(decryptShopStore).toHaveBeenCalledWith(state, secretKey); + }); + + it('returns state unchanged for unrecognised key on outbound', () => { + const {outFn} = getTransform(); + const state: any = {foo: 'baz'}; + const result = outFn(state, 'RATE'); + expect(result).toBe(state); + }); + + it('inbound: handles encrypt error by calling logTransformFailure', () => { + (encryptWalletStore as jest.Mock).mockImplementationOnce(() => { + throw new Error('encrypt failed'); + }); + const {inFn} = getTransform(); + const state: any = {keys: {}}; + // Should not throw — the try/catch inside swallows it + expect(() => inFn(state, 'WALLET')).not.toThrow(); + }); + + it('outbound: handles decrypt error by calling logTransformFailure', () => { + (decryptWalletStore as jest.Mock).mockImplementationOnce(() => { + throw new Error('decrypt failed'); + }); + const {outFn} = getTransform(); + const state: any = {keys: {}}; + expect(() => outFn(state, 'WALLET')).not.toThrow(); + }); +}); diff --git a/src/store/wallet/effects/create/create.spec.ts b/src/store/wallet/effects/create/create.spec.ts new file mode 100644 index 0000000000..9d2c27ab7a --- /dev/null +++ b/src/store/wallet/effects/create/create.spec.ts @@ -0,0 +1,725 @@ +/** + * Tests for create.ts + * + * Strategy: + * - BwcProvider and hardware dependencies are mocked heavily. + * - We focus on exported functions and the branching logic accessible through + * them: startCreateKey, startCreateKeyWithOpts, createWalletWithOpts, + * detectAndCreateTokensForEachEvmWallet, addWallet. + * - getDecryptPassword is already covered by getDecryptPassword.spec.js. + */ + +import configureTestStore from '@test/store'; +import { + startCreateKey, + startCreateKeyWithOpts, + createWalletWithOpts, + detectAndCreateTokensForEachEvmWallet, + addWallet, +} from './create'; +import {Network} from '../../../../constants'; + +// --------------------------------------------------------------------------- +// Mock heavy native dependencies +// --------------------------------------------------------------------------- + +jest.mock('../../../../managers/LogManager', () => ({ + logManager: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../../../../managers/TokenManager', () => ({ + tokenManager: {getTokenOptions: jest.fn(() => ({tokenOptionsByAddress: {}}))}, +})); + +jest.mock('../../../../utils/helper-methods', () => ({ + sleep: jest.fn(() => Promise.resolve()), + addTokenChainSuffix: jest.fn( + (addr: string, chain: string) => `${addr}_e.${chain}`, + ), + checkEncryptedKeysForEddsaMigration: jest.fn(() => () => Promise.resolve()), + isL2NoSideChainNetwork: jest.fn(() => false), + getAccount: jest.fn(() => 0), + getErrorString: jest.fn((e: any) => String(e)), + isL2Chain: jest.fn(() => false), + sleep: jest.fn(() => Promise.resolve()), + getRateByCurrencyName: jest.fn(() => undefined), + formatFiatAmount: jest.fn(() => '0.00'), +})); + +jest.mock('../../../app/app.effects', () => ({ + subscribeEmailNotifications: jest.fn(() => ({type: 'MOCK_EMAIL_NOTIF'})), + subscribePushNotifications: jest.fn(() => ({type: 'MOCK_PUSH_NOTIF'})), +})); + +jest.mock('../../../app/app.actions', () => ({ + ...jest.requireActual('../../../app/app.actions'), + showDecryptPasswordModal: jest.fn(() => ({type: 'MOCK_SHOW_DECRYPT'})), + dismissDecryptPasswordModal: jest.fn(() => ({type: 'MOCK_DISMISS_DECRYPT'})), +})); + +jest.mock('../../../analytics/analytics.effects', () => ({ + Analytics: {track: jest.fn(() => ({type: 'MOCK_ANALYTICS'}))}, +})); + +// Mock wallet utils (do NOT use requireActual — it pulls in Wallet.tsx → huge chain) +jest.mock('../../utils/wallet', () => ({ + buildKeyObj: jest.fn(({key, wallets, backupComplete}) => ({ + id: 'mock-key-id', + methods: key, + properties: {}, + wallets: wallets || [], + isPrivKeyEncrypted: false, + backupComplete: backupComplete || false, + })), + buildWalletObj: jest.fn((credentials, _tokenOpts) => ({ + ...credentials, + _isMockWalletObj: true, + })), + checkEncryptPassword: jest.fn(() => true), + checkEncryptedKeys: jest.fn(() => true), + mapAbbreviationAndName: jest.fn(() => _dispatch => ({ + currencyAbbreviation: 'btc', + currencyName: 'Bitcoin', + })), + isCacheKeyStale: jest.fn(() => false), +})); + +// Mock createWalletAddress +jest.mock('../address/address', () => ({ + createWalletAddress: jest.fn( + () => () => Promise.resolve('mock-receive-address'), + ), +})); + +// Mock status effects +jest.mock('../status/status', () => ({ + startUpdateAllKeyAndWalletStatus: jest.fn(() => () => Promise.resolve()), + startUpdateWalletStatus: jest.fn(() => () => Promise.resolve()), + getTokenContractInfo: jest.fn(() => + Promise.resolve({symbol: 'MOCK', name: 'Mock Token', decimals: 18}), + ), +})); + +// Mock currency utils +jest.mock('../../utils/currency', () => ({ + IsERCToken: jest.fn(() => false), + IsSegwitCoin: jest.fn((coin: string) => coin === 'btc'), + IsSVMChain: jest.fn(() => false), + IsVMChain: jest.fn((chain: string) => + ['eth', 'matic', 'sol'].includes(chain), + ), + GetPrecision: jest.fn(() => ({unitDecimals: 8})), +})); + +// Mock moralis effects +jest.mock('../../../moralis/moralis.effects', () => ({ + getERC20TokenBalanceByWallet: jest.fn(() => () => Promise.resolve([])), + getSVMTokenBalanceByWallet: jest.fn(() => () => Promise.resolve([])), +})); + +jest.mock('../currencies/currencies', () => ({ + addCustomTokenOption: jest.fn(() => ({type: 'MOCK_CUSTOM_TOKEN'})), +})); + +// --------------------------------------------------------------------------- +// BwcProvider mock — the centrepiece +// --------------------------------------------------------------------------- + +const mockBwcClient = { + fromString: jest.fn(), + fromObj: jest.fn(), + createWallet: jest.fn( + (name: any, me: any, m: any, n: any, opts: any, cb: any) => cb(null), + ), + credentials: { + coin: 'btc', + chain: 'btc', + token: undefined, + walletId: 'mock-wallet-id', + rootPath: undefined, + getTokenCredentials: jest.fn((tokenOpts: any, chain: string) => ({ + walletId: 'mock-token-wallet-id', + token: {address: '0xTokenAddress'}, + })), + }, +}; + +const mockKeyMethods = { + createCredentials: jest.fn(() => 'mock-credentials-string'), + addKeyByAlgorithm: jest.fn(), + toObj: jest.fn(() => ({})), +}; + +jest.mock('../../../../lib/bwc', () => ({ + BwcProvider: { + getInstance: jest.fn(() => ({ + getClient: jest.fn(() => mockBwcClient), + createKey: jest.fn(() => mockKeyMethods), + getErrors: jest.fn(() => ({})), + })), + API: {}, + }, +})); + +// Also mock buy-crypto effects to prevent the deep import chain +jest.mock('../../../../store/buy-crypto/buy-crypto.effects', () => ({ + calculateUsdToAltFiat: jest.fn(() => () => 0), +})); + +// Mock transactions module to avoid BWC.getErrors at module level +jest.mock('../transactions/transactions', () => ({ + GetTransactionHistory: jest.fn(() => () => Promise.resolve([])), + BWS_TX_HISTORY_LIMIT: 1001, + TX_HISTORY_LIMIT: 25, +})); + +// --------------------------------------------------------------------------- +// Base state +// --------------------------------------------------------------------------- + +const baseState = { + APP: { + network: Network.mainnet, + notificationsAccepted: false, + emailNotifications: {accepted: false, email: null}, + brazeEid: null, + defaultLanguage: 'en', + altCurrencyList: [{isoCode: 'USD', name: 'US Dollar'}], + }, + WALLET: { + keys: {}, + customTokenOptionsByAddress: {}, + }, + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, +}; + +// --------------------------------------------------------------------------- +// Helper to build a minimal mock wallet +// --------------------------------------------------------------------------- +const makeMockWallet = (overrides: Record = {}): any => ({ + id: 'mock-wallet-1', + chain: 'eth', + currencyAbbreviation: 'eth', + network: Network.mainnet, + receiveAddress: '0xMockAddress', + tokens: [], + credentials: { + coin: 'eth', + chain: 'eth', + token: undefined, + walletId: 'mock-wallet-1', + rootPath: undefined, + getTokenCredentials: jest.fn((tokenOpts: any) => ({ + walletId: `mock-wallet-1-0xtoken`, + token: {address: '0xToken'}, + })), + }, + balance: {sat: 0, crypto: '0', totalBalance: 0}, + preferences: { + tokenAddresses: [], + maticTokenAddresses: [], + opTokenAddresses: [], + arbTokenAddresses: [], + baseTokenAddresses: [], + solTokenAddresses: [], + }, + savePreferences: jest.fn((_prefs: any, cb: any) => cb(null)), + isHardwareWallet: false, + hardwareData: undefined, + ...overrides, +}); + +// --------------------------------------------------------------------------- +// startCreateKey +// --------------------------------------------------------------------------- +describe('startCreateKey', () => { + beforeEach(() => jest.clearAllMocks()); + + it('resolves with a key object on success', async () => { + const store = configureTestStore(baseState); + + const result = await store.dispatch( + startCreateKey( + [{chain: 'btc', currencyAbbreviation: 'btc', isToken: false}], + 'onboarding', + ), + ); + + expect(result).toBeDefined(); + expect(result.id).toBe('mock-key-id'); + }); + + it('does not dispatch Analytics.track when context is "onboarding"', async () => { + const {Analytics} = require('../../../analytics/analytics.effects'); + const store = configureTestStore(baseState); + + await store.dispatch( + startCreateKey( + [{chain: 'btc', currencyAbbreviation: 'btc', isToken: false}], + 'onboarding', + ), + ); + + expect(Analytics.track).not.toHaveBeenCalled(); + }); + + it('dispatches Analytics.track when context is not "onboarding"', async () => { + const {Analytics} = require('../../../analytics/analytics.effects'); + const store = configureTestStore(baseState); + + await store.dispatch( + startCreateKey( + [{chain: 'btc', currencyAbbreviation: 'btc', isToken: false}], + // no context or other context + ), + ); + + expect(Analytics.track).toHaveBeenCalledWith('Created Key'); + }); + + it('resolves even when individual wallet creation fails (error is caught internally)', async () => { + // createMultipleWallets catches wallet creation errors and continues (returns null for that wallet) + mockBwcClient.createWallet.mockImplementationOnce( + (_n: any, _me: any, _m: any, _nn: any, _opts: any, cb: any) => + cb(Object.assign(new Error('wallet fail'), {name: 'bwc.ErrorOTHER'})), + ); + + const store = configureTestStore(baseState); + + // Errors in individual wallet creation are swallowed → result still resolves + const result = await store.dispatch( + startCreateKey([ + {chain: 'btc', currencyAbbreviation: 'btc', isToken: false}, + ]), + ); + + expect(result).toBeDefined(); + expect(result.id).toBe('mock-key-id'); + // wallets array will be empty because creation failed + expect(result.wallets.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// startCreateKeyWithOpts +// --------------------------------------------------------------------------- +describe('startCreateKeyWithOpts', () => { + beforeEach(() => jest.clearAllMocks()); + + it('resolves with a key object when seed import succeeds', async () => { + const store = configureTestStore(baseState); + + const result = await store.dispatch( + startCreateKeyWithOpts({ + seedType: 'mnemonic', + mnemonic: 'test test test test test test test test test test test test', + }), + ); + + expect(result).toBeDefined(); + expect(result.id).toBe('mock-key-id'); + expect(result.backupComplete).toBe(true); + }); + + it('resolves with backupComplete=true even when individual wallet creation fails', async () => { + // createMultipleWallets catches wallet creation errors and continues + mockBwcClient.createWallet.mockImplementationOnce( + (_n: any, _me: any, _m: any, _nn: any, _opts: any, cb: any) => + cb(Object.assign(new Error('wallet error'), {name: 'bwc.ErrorOTHER'})), + ); + + const store = configureTestStore(baseState); + + const result = await store.dispatch( + startCreateKeyWithOpts({seedType: 'mnemonic', mnemonic: 'bad words'}), + ); + + expect(result).toBeDefined(); + expect(result.backupComplete).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// createWalletWithOpts +// --------------------------------------------------------------------------- +describe('createWalletWithOpts', () => { + beforeEach(() => jest.clearAllMocks()); + + it('resolves with a bwcClient on success', async () => { + // bwcClient.createWallet calls cb(null) → resolves + const store = configureTestStore(baseState); + + const result = await store.dispatch( + createWalletWithOpts({ + key: mockKeyMethods as any, + opts: { + coin: 'btc', + chain: 'btc', + networkName: 'livenet', + account: 0, + n: 1, + m: 1, + name: 'My Wallet', + }, + }), + ); + + expect(result).toBeDefined(); + expect(mockBwcClient.createWallet).toHaveBeenCalled(); + }); + + it('rejects on generic error from createWallet', async () => { + const genericError = Object.assign(new Error('generic wallet error'), { + name: 'bwc.ErrorOTHER', + }); + mockBwcClient.createWallet.mockImplementationOnce( + (_n: any, _me: any, _m: any, _nn: any, _opts: any, cb: any) => + cb(genericError), + ); + + const store = configureTestStore(baseState); + + await expect( + store.dispatch( + createWalletWithOpts({ + key: mockKeyMethods as any, + opts: {coin: 'btc', chain: 'btc'}, + }), + ), + ).rejects.toThrow('generic wallet error'); + }); + + it('increments account and retries on COPAYER_REGISTERED error', async () => { + const copayerError = { + name: 'bwc.ErrorCOPAYER_REGISTERED', + message: 'registered', + }; + let callCount = 0; + mockBwcClient.createWallet.mockImplementation( + (_n: any, _me: any, _m: any, _nn: any, _opts: any, cb: any) => { + callCount++; + if (callCount === 1) { + cb(copayerError); + } else { + cb(null); + } + }, + ); + + const store = configureTestStore(baseState); + + const result = await store.dispatch( + createWalletWithOpts({ + key: mockKeyMethods as any, + opts: {coin: 'btc', chain: 'btc', account: 0}, + }), + ); + + expect(result).toBeDefined(); + expect(callCount).toBeGreaterThanOrEqual(2); + + // Restore default implementation + mockBwcClient.createWallet.mockImplementation( + (_n: any, _me: any, _m: any, _nn: any, _opts: any, cb: any) => cb(null), + ); + }); + + it('rejects when COPAYER_REGISTERED and account >= 20', async () => { + const copayerError = { + name: 'bwc.ErrorCOPAYER_REGISTERED', + message: 'registered', + }; + mockBwcClient.createWallet.mockImplementation( + (_n: any, _me: any, _m: any, _nn: any, _opts: any, cb: any) => + cb(copayerError), + ); + + const store = configureTestStore(baseState); + + await expect( + store.dispatch( + createWalletWithOpts({ + key: mockKeyMethods as any, + opts: {coin: 'btc', chain: 'btc', account: 20}, + }), + ), + ).rejects.toThrow('20 Wallet limit'); + + // Restore default implementation + mockBwcClient.createWallet.mockImplementation( + (_n: any, _me: any, _m: any, _nn: any, _opts: any, cb: any) => cb(null), + ); + }); + + it('rejects when fromString throws (try/catch path)', async () => { + mockBwcClient.fromString.mockImplementationOnce(() => { + throw new Error('fromString failed'); + }); + + const store = configureTestStore(baseState); + + await expect( + store.dispatch( + createWalletWithOpts({ + key: mockKeyMethods as any, + opts: {coin: 'btc', chain: 'btc'}, + }), + ), + ).rejects.toThrow('fromString failed'); + }); +}); + +// --------------------------------------------------------------------------- +// detectAndCreateTokensForEachEvmWallet +// --------------------------------------------------------------------------- +describe('detectAndCreateTokensForEachEvmWallet', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns early (no error) when key has no VM wallets', async () => { + const {IsVMChain} = require('../../utils/currency'); + IsVMChain.mockReturnValue(false); + + const key: any = { + id: 'key-1', + wallets: [makeMockWallet({chain: 'btc', currencyAbbreviation: 'btc'})], + }; + + const store = configureTestStore(baseState); + await expect( + store.dispatch(detectAndCreateTokensForEachEvmWallet({key})), + ).resolves.toBeUndefined(); + }); + + it('processes EVM wallets with no token balances (empty moralis response)', async () => { + const {IsVMChain, IsERCToken} = require('../../utils/currency'); + IsVMChain.mockReturnValue(true); + IsERCToken.mockReturnValue(false); + + const { + getERC20TokenBalanceByWallet, + } = require('../../../moralis/moralis.effects'); + getERC20TokenBalanceByWallet.mockReturnValue(() => Promise.resolve([])); + + const wallet = makeMockWallet({chain: 'eth', currencyAbbreviation: 'eth'}); + const key: any = {id: 'key-1', wallets: [wallet]}; + + const store = configureTestStore(baseState); + await expect( + store.dispatch(detectAndCreateTokensForEachEvmWallet({key})), + ).resolves.toBeUndefined(); + }); + + it('skips wallets that are ERC tokens themselves', async () => { + const {IsVMChain, IsERCToken} = require('../../utils/currency'); + IsVMChain.mockReturnValue(true); + IsERCToken.mockReturnValue(true); // wallet IS an ERC token → skip + + const wallet = makeMockWallet({chain: 'eth', currencyAbbreviation: 'usdc'}); + const key: any = {id: 'key-1', wallets: [wallet]}; + + const store = configureTestStore(baseState); + await expect( + store.dispatch(detectAndCreateTokensForEachEvmWallet({key})), + ).resolves.toBeUndefined(); + + // No moralis calls since all wallets are ERC tokens + const { + getERC20TokenBalanceByWallet, + } = require('../../../moralis/moralis.effects'); + expect(getERC20TokenBalanceByWallet).not.toHaveBeenCalled(); + }); + + it('skips wallets without a receiveAddress', async () => { + const {IsVMChain, IsERCToken} = require('../../utils/currency'); + IsVMChain.mockReturnValue(true); + IsERCToken.mockReturnValue(false); + + const wallet = makeMockWallet({chain: 'eth', receiveAddress: undefined}); + const key: any = {id: 'key-1', wallets: [wallet]}; + + const store = configureTestStore(baseState); + await expect( + store.dispatch(detectAndCreateTokensForEachEvmWallet({key})), + ).resolves.toBeUndefined(); + + const { + getERC20TokenBalanceByWallet, + } = require('../../../moralis/moralis.effects'); + expect(getERC20TokenBalanceByWallet).not.toHaveBeenCalled(); + }); + + it('filters by chain when chain param is provided', async () => { + const {IsVMChain, IsERCToken} = require('../../utils/currency'); + IsVMChain.mockReturnValue(true); + IsERCToken.mockReturnValue(false); + + const { + getERC20TokenBalanceByWallet, + } = require('../../../moralis/moralis.effects'); + getERC20TokenBalanceByWallet.mockReturnValue(() => Promise.resolve([])); + + const wallet = makeMockWallet({ + chain: 'matic', + currencyAbbreviation: 'matic', + }); + const key: any = {id: 'key-1', wallets: [wallet]}; + + const store = configureTestStore(baseState); + // Pass chain='eth' → matic wallet should be filtered out + await store.dispatch( + detectAndCreateTokensForEachEvmWallet({key, chain: 'eth'}), + ); + + expect(getERC20TokenBalanceByWallet).not.toHaveBeenCalled(); + }); + + it('skips already-present token when tokenAddress param matches existing token', async () => { + const {IsVMChain, IsERCToken} = require('../../utils/currency'); + IsVMChain.mockReturnValue(true); + IsERCToken.mockReturnValue(false); + + const existingTokenId = 'wallet-1-0xexistingtoken'; + const wallet = makeMockWallet({ + id: 'wallet-1', + chain: 'eth', + tokens: [existingTokenId], + }); + const key: any = {id: 'key-1', wallets: [wallet]}; + + const store = configureTestStore(baseState); + // tokenAddress matches existing → wallet filtered out → no moralis call + await store.dispatch( + detectAndCreateTokensForEachEvmWallet({ + key, + tokenAddress: '0xexistingtoken', + }), + ); + + const { + getERC20TokenBalanceByWallet, + } = require('../../../moralis/moralis.effects'); + expect(getERC20TokenBalanceByWallet).not.toHaveBeenCalled(); + }); + + it('handles errors gracefully without throwing', async () => { + const {IsVMChain, IsERCToken} = require('../../utils/currency'); + IsVMChain.mockReturnValue(true); + IsERCToken.mockReturnValue(false); + + const { + getERC20TokenBalanceByWallet, + } = require('../../../moralis/moralis.effects'); + getERC20TokenBalanceByWallet.mockReturnValue(() => + Promise.reject(new Error('moralis down')), + ); + + const wallet = makeMockWallet({chain: 'eth', currencyAbbreviation: 'eth'}); + const key: any = {id: 'key-1', wallets: [wallet]}; + + const store = configureTestStore(baseState); + // Should not throw + await expect( + store.dispatch(detectAndCreateTokensForEachEvmWallet({key})), + ).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// addWallet – token wallet path (isToken=true) +// --------------------------------------------------------------------------- +describe('addWallet – token path', () => { + beforeEach(() => jest.clearAllMocks()); + + it('rejects when key is encrypted and no password is provided for non-token', async () => { + const encryptedKey: any = { + id: 'key-enc', + isPrivKeyEncrypted: true, + wallets: [], + methods: mockKeyMethods, + properties: {xPrivKeyEDDSA: 'some-key'}, + }; + + const store = configureTestStore(baseState); + await expect( + store.dispatch( + addWallet({ + key: encryptedKey, + currency: {chain: 'btc', currencyAbbreviation: 'btc', isToken: false}, + options: {}, + }), + ), + ).rejects.toThrow('A password is required'); + }); + + it('accepts password for encrypted non-token key when password is provided', async () => { + // When password is provided, the encrypted key check passes + const encryptedKey: any = { + id: 'key-enc', + isPrivKeyEncrypted: true, + wallets: [], + methods: { + ...mockKeyMethods, + addKeyByAlgorithm: jest.fn(), + toObj: jest.fn(() => ({})), + }, + properties: {xPrivKeyEDDSA: 'some-eddsa-key'}, + }; + + const store = configureTestStore(baseState); + + // Providing a password should pass the check (no "password required" error) + // The wallet creation will proceed - either resolving or rejecting for other reasons + let errorMessage = ''; + try { + await store.dispatch( + addWallet({ + key: encryptedKey, + currency: {chain: 'btc', currencyAbbreviation: 'btc', isToken: false}, + options: {password: 'mypassword'}, + }), + ); + } catch (e: any) { + errorMessage = e?.message || String(e); + } + + // Should NOT be the "password required" error + expect(errorMessage).not.toContain('A password is required'); + }); + + it('resolves for non-encrypted key without password', async () => { + // Non-encrypted key doesn't need a password + const normalKey: any = { + id: 'key-1', + isPrivKeyEncrypted: false, + wallets: [], + methods: { + ...mockKeyMethods, + addKeyByAlgorithm: jest.fn(), + toObj: jest.fn(() => ({})), + }, + properties: {xPrivKeyEDDSA: 'some-eddsa-key'}, + }; + + const store = configureTestStore(baseState); + + const result = await store.dispatch( + addWallet({ + key: normalKey, + currency: {chain: 'btc', currencyAbbreviation: 'btc', isToken: false}, + options: {}, + }), + ); + + expect(result).toBeDefined(); + }); +}); diff --git a/src/store/wallet/effects/create/getDecryptPassword.spec.js b/src/store/wallet/effects/create/getDecryptPassword.spec.js index 40d036d57e..9c5a9557ce 100644 --- a/src/store/wallet/effects/create/getDecryptPassword.spec.js +++ b/src/store/wallet/effects/create/getDecryptPassword.spec.js @@ -6,6 +6,13 @@ import { } from '../../../app/app.actions'; import {checkEncryptPassword} from '../../utils/wallet'; +// checkEncryptedKeysForEddsaMigration was added after this test was written. +// Mock it as a no-op to prevent crashes when tests use fake string keys. +jest.mock('../../../../utils/helper-methods', () => ({ + ...jest.requireActual('../../../../utils/helper-methods'), + checkEncryptedKeysForEddsaMigration: jest.fn(() => () => Promise.resolve()), +})); + /** * Mock showDecryptPasswordModal and Spy on dismissDecryptPasswordModal */ diff --git a/src/store/wallet/effects/currencies/currencies.spec.ts b/src/store/wallet/effects/currencies/currencies.spec.ts new file mode 100644 index 0000000000..f980db7a2c --- /dev/null +++ b/src/store/wallet/effects/currencies/currencies.spec.ts @@ -0,0 +1,357 @@ +import configureTestStore from '@test/store'; +import axios from 'axios'; +import { + startGetTokenOptions, + addCustomTokenOption, + startCustomTokensMigration, +} from './currencies'; +import {tokenManager} from '../../../../managers/TokenManager'; + +// ---------- module-level mocks ---------- + +jest.mock('../../../../managers/LogManager', () => ({ + logManager: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, +})); + +jest.mock('../../../../managers/TokenManager', () => ({ + tokenManager: { + setTokenOptions: jest.fn(), + getTokenOptions: jest.fn(() => ({ + tokenOptionsByAddress: {}, + tokenDataByAddress: {}, + })), + addListener: jest.fn(), + removeListener: jest.fn(), + }, +})); + +// Provide a minimal constant set so the loop only runs over 'eth' +jest.mock('../../../../constants/currencies', () => { + const actual = jest.requireActual('../../../../constants/currencies'); + return { + ...actual, + SUPPORTED_VM_TOKENS: ['eth'], + // Keep BitpaySupportedTokens empty so custom tokens aren't filtered + BitpaySupportedTokens: {}, + }; +}); + +// Mock wallet actions so we can spy on them +jest.mock('../../wallet.actions', () => ({ + ...jest.requireActual('../../wallet.actions'), + failedGetTokenOptions: jest.fn(() => ({ + type: 'WALLET/FAILED_GET_TOKEN_OPTIONS', + })), + successGetCustomTokenOptions: jest.fn(payload => ({ + type: 'WALLET/SUCCESS_GET_CUSTOM_TOKEN_OPTIONS', + payload, + })), +})); + +// Mock app store actions for APP_TOKENS_DATA_LOADED +jest.mock('../../../app', () => { + const actual = jest.requireActual('../../../app'); + return { + ...actual, + AppActions: { + ...actual.AppActions, + appTokensDataLoaded: jest.fn(() => ({type: 'APP_TOKENS_DATA_LOADED'})), + }, + }; +}); + +// Mock BLOCKCHAIN_EXPLORERS to cover any chain key, +// while keeping other real config values (BASE_BITPAY_URLS, etc.) +jest.mock('../../../../constants/config', () => { + const actual = jest.requireActual('../../../../constants/config'); + return { + ...actual, + BASE_BWS_URL: 'https://bws.test', + BLOCKCHAIN_EXPLORERS: new Proxy( + {}, + { + get: (_target, _prop) => ({ + livenet: 'https://explorer.test', + testnet: 'https://explorer-test.test', + }), + }, + ), + }; +}); + +const mockedAxios = axios as jest.Mocked; +const mockedSetTokenOptions = tokenManager.setTokenOptions as jest.Mock; + +// Grab the mocked action creators after jest.mock has set them up +const {failedGetTokenOptions, successGetCustomTokenOptions} = jest.requireMock( + '../../wallet.actions', +); +const {AppActions} = jest.requireMock('../../../app'); + +// Helper: a minimal valid token object +const makeToken = (overrides: Record = {}) => ({ + address: '0xdeadbeef', + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + logoURI: 'https://example.com/logo.png', + ...overrides, +}); + +describe('startGetTokenOptions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls tokenManager.setTokenOptions after a successful response with an array of tokens', async () => { + const token = makeToken(); + mockedAxios.get.mockResolvedValueOnce({data: [token]}); + + const store = configureTestStore({}); + await store.dispatch(startGetTokenOptions()); + + expect(mockedSetTokenOptions).toHaveBeenCalledTimes(1); + }); + + it('dispatches APP_TOKENS_DATA_LOADED on success', async () => { + const token = makeToken(); + mockedAxios.get.mockResolvedValueOnce({data: [token]}); + + const store = configureTestStore({}); + await store.dispatch(startGetTokenOptions()); + + expect(AppActions.appTokensDataLoaded).toHaveBeenCalled(); + }); + + it('returns early (no setTokenOptions) when the API response is not an array', async () => { + // The function checks !Array.isArray(tokens) and returns early + mockedAxios.get.mockResolvedValueOnce({data: {someKey: 'someValue'}}); + + const store = configureTestStore({}); + await store.dispatch(startGetTokenOptions()); + + expect(mockedSetTokenOptions).not.toHaveBeenCalled(); + }); + + it('catches per-chain network error; tokens stays as non-array so returns early', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('network error')); + + const store = configureTestStore({}); + await store.dispatch(startGetTokenOptions()); + + // tokens was never reassigned → stays as {} (not array) → early return + expect(mockedSetTokenOptions).not.toHaveBeenCalled(); + }); + + it('dispatches failedGetTokenOptions when tokenManager.setTokenOptions throws', async () => { + // The outer catch is triggered by something outside the inner axios try/catch. + // Make setTokenOptions throw to simulate an unexpected outer error. + const token = makeToken(); + mockedAxios.get.mockResolvedValueOnce({data: [token]}); + mockedSetTokenOptions.mockImplementationOnce(() => { + throw new Error('storage failure'); + }); + + const store = configureTestStore({}); + await store.dispatch(startGetTokenOptions()); + + expect(failedGetTokenOptions).toHaveBeenCalled(); + // Even on failure, APP_TOKENS_DATA_LOADED is dispatched + expect(AppActions.appTokensDataLoaded).toHaveBeenCalled(); + }); + + it('includes the token in setTokenOptions when it is not a BitpaySupported token', async () => { + const token = makeToken({address: '0xdeadbeef', symbol: 'TEST'}); + mockedAxios.get.mockResolvedValueOnce({data: [token]}); + + const store = configureTestStore({}); + await store.dispatch(startGetTokenOptions()); + + expect(mockedSetTokenOptions).toHaveBeenCalledTimes(1); + const {tokenOptionsByAddress} = mockedSetTokenOptions.mock.calls[0][0]; + expect(Object.keys(tokenOptionsByAddress).length).toBeGreaterThanOrEqual(1); + }); + + it('passes tokenDataByAddress with correct fields to setTokenOptions', async () => { + const token = makeToken({ + address: '0xabc123', + name: 'My Token', + symbol: 'MYT', + decimals: 8, + }); + mockedAxios.get.mockResolvedValueOnce({data: [token]}); + + const store = configureTestStore({}); + await store.dispatch(startGetTokenOptions()); + + expect(mockedSetTokenOptions).toHaveBeenCalledTimes(1); + const {tokenDataByAddress} = mockedSetTokenOptions.mock.calls[0][0]; + const values = Object.values(tokenDataByAddress) as any[]; + expect(values.length).toBe(1); + expect(values[0].coin).toBe('myt'); + expect(values[0].unitInfo.unitDecimals).toBe(8); + expect(values[0].properties.isCustom).toBe(true); + }); +}); + +describe('addCustomTokenOption', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls successGetCustomTokenOptions for a non-bitpay token', async () => { + const token = makeToken(); + const store = configureTestStore({}); + await store.dispatch(addCustomTokenOption(token, 'eth')); + + expect(successGetCustomTokenOptions).toHaveBeenCalledTimes(1); + const arg = successGetCustomTokenOptions.mock.calls[0][0]; + expect(arg.customTokenOptionsByAddress).toBeDefined(); + expect(arg.customTokenDataByAddress).toBeDefined(); + }); + + it('populates token data: strips (PoS), lowercases coin, sets isCustom and unitInfo', async () => { + const token = makeToken({ + address: '0xaabbcc', + name: 'My Token (PoS)', + symbol: 'MTK', + decimals: 6, + }); + const store = configureTestStore({}); + await store.dispatch(addCustomTokenOption(token, 'eth')); + + expect(successGetCustomTokenOptions).toHaveBeenCalledTimes(1); + const {customTokenDataByAddress} = + successGetCustomTokenOptions.mock.calls[0][0]; + const values = Object.values(customTokenDataByAddress) as any[]; + expect(values.length).toBe(1); + + const tokenData = values[0]; + expect(tokenData.name).toBe('My Token'); + expect(tokenData.coin).toBe('mtk'); + expect(tokenData.unitInfo.unitDecimals).toBe(6); + expect(tokenData.unitInfo.unitToSatoshi).toBe(10 ** 6); + expect(tokenData.properties.isCustom).toBe(true); + expect(tokenData.properties.isERCToken).toBe(true); + expect(tokenData.properties.singleAddress).toBe(true); + }); + + it('returns early without dispatching if token address is already in BitpaySupportedTokens', async () => { + const {getCurrencyAbbreviation} = jest.requireActual( + '../../../../utils/helper-methods', + ); + const token = makeToken({address: '0xdeadbeef', symbol: 'TEST'}); + const abbr = getCurrencyAbbreviation(token.address, 'eth'); + + const currenciesMock = jest.requireMock('../../../../constants/currencies'); + currenciesMock.BitpaySupportedTokens[abbr] = {coin: 'test'}; + + const store = configureTestStore({}); + await store.dispatch(addCustomTokenOption(token, 'eth')); + + expect(successGetCustomTokenOptions).not.toHaveBeenCalled(); + expect(failedGetTokenOptions).not.toHaveBeenCalled(); + + delete currenciesMock.BitpaySupportedTokens[abbr]; + }); + + it('stores the original token object in customTokenOptionsByAddress', async () => { + const token = makeToken({address: '0x111222', symbol: 'FOO', decimals: 8}); + const store = configureTestStore({}); + await store.dispatch(addCustomTokenOption(token, 'eth')); + + expect(successGetCustomTokenOptions).toHaveBeenCalledTimes(1); + const {customTokenOptionsByAddress} = + successGetCustomTokenOptions.mock.calls[0][0]; + const storedToken = Object.values(customTokenOptionsByAddress)[0]; + expect(storedToken).toEqual(token); + }); + + it('calls failedGetTokenOptions on internal error', async () => { + // Remove 'eth' from the BLOCKCHAIN_EXPLORERS proxy by temporarily making it undefined + const configMock = jest.requireMock('../../../../constants/config'); + const originalExplorers = configMock.BLOCKCHAIN_EXPLORERS; + // Override with a proxy that returns undefined for 'eth' to trigger a TypeError + configMock.BLOCKCHAIN_EXPLORERS = new Proxy( + {}, + { + get: (_t, prop) => + prop === 'eth' ? undefined : {livenet: '', testnet: ''}, + }, + ); + + const token = makeToken(); + const store = configureTestStore({}); + await store.dispatch(addCustomTokenOption(token, 'eth')); + + expect(failedGetTokenOptions).toHaveBeenCalled(); + + configMock.BLOCKCHAIN_EXPLORERS = originalExplorers; + }); +}); + +describe('startCustomTokensMigration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves without error when customTokenOptions is empty', async () => { + const store = configureTestStore({ + WALLET: {customTokenOptions: {}, customTokenData: {}}, + }); + await expect( + store.dispatch(startCustomTokensMigration()), + ).resolves.toBeUndefined(); + }); + + it('resolves without error when customTokenOptions is undefined', async () => { + const store = configureTestStore({ + WALLET: {customTokenOptions: undefined, customTokenData: {}}, + }); + await expect( + store.dispatch(startCustomTokensMigration()), + ).resolves.toBeUndefined(); + }); + + it('runs migration logic for each entry in customTokenOptions without throwing', async () => { + const customToken = { + name: 'My Custom Token', + symbol: 'MCT', + decimals: '18', + address: '0x123abc', + }; + const state = { + WALLET: { + customTokenOptions: {MCT: customToken}, + customTokenData: {mct: {chain: 'eth'}}, + }, + }; + const store = configureTestStore(state); + await expect( + store.dispatch(startCustomTokensMigration()), + ).resolves.toBeUndefined(); + }); + + it('uses "eth" as the default chain when customTokenData entry is missing', async () => { + const customToken = { + name: 'No Chain Token', + symbol: 'NCT', + decimals: '8', + address: '0xfedcba', + }; + const state = { + WALLET: { + customTokenOptions: {NCT: customToken}, + customTokenData: {}, // no chain info + }, + }; + const store = configureTestStore(state); + await expect( + store.dispatch(startCustomTokensMigration()), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/store/wallet/effects/errors/errors.spec.ts b/src/store/wallet/effects/errors/errors.spec.ts new file mode 100644 index 0000000000..41d2b66491 --- /dev/null +++ b/src/store/wallet/effects/errors/errors.spec.ts @@ -0,0 +1,185 @@ +import configureTestStore from '@test/store'; +import {showWalletError} from './errors'; +import {showBottomNotificationModal} from '../../../app/app.actions'; +import {ongoingProcessManager} from '../../../../managers/OngoingProcessManager'; + +// Mock sleep so tests run without real delays +jest.mock('../../../../utils/helper-methods', () => ({ + ...jest.requireActual('../../../../utils/helper-methods'), + sleep: jest.fn(() => Promise.resolve()), +})); + +// Spy on ongoingProcessManager.hide +jest.mock('../../../../managers/OngoingProcessManager', () => ({ + ongoingProcessManager: { + hide: jest.fn(), + show: jest.fn(), + }, +})); + +// Mock showBottomNotificationModal so we can inspect the dispatched payload +jest.mock('../../../app/app.actions', () => ({ + ...jest.requireActual('../../../app/app.actions'), + showBottomNotificationModal: jest.fn(payload => ({ + type: 'MOCK_SHOW_BOTTOM_NOTIFICATION', + payload, + })), + dismissBottomNotificationModal: jest.fn(() => ({ + type: 'MOCK_DISMISS_BOTTOM_NOTIFICATION', + })), +})); + +const mockedShow = showBottomNotificationModal as jest.Mock; + +describe('showWalletError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('hides the ongoing process loader before showing the error', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError()); + expect(ongoingProcessManager.hide).toHaveBeenCalledTimes(1); + }); + + it('dispatches showBottomNotificationModal with type error', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError()); + expect(mockedShow).toHaveBeenCalledTimes(1); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.type).toBe('error'); + expect(payload.enableBackdropDismiss).toBe(true); + }); + + it('shows default title/message for unknown/undefined type', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError()); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('Error'); + expect(payload.message).toBe('Unknown Error'); + }); + + it('shows correct message for walletNotSupported', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('walletNotSupported')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('Wallet not supported'); + expect(payload.message).toContain('not supported'); + }); + + it('shows correct message for walletNotSupportedToBuy', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('walletNotSupportedToBuy')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('Wallet not supported'); + expect(payload.message).toContain('buying'); + }); + + it('shows correct message for noSpendableFunds', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('noSpendableFunds')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No spendable balance'); + }); + + it('shows correct message for needsBackup', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('needsBackup')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('Needs backup'); + }); + + it('shows correct message for walletNotCompleted', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('walletNotCompleted')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('Incomplete Wallet'); + }); + + it('shows generic no-coin message for noWalletsAbleToBuy without coin', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('noWalletsAbleToBuy')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No wallets'); + expect(payload.message).toContain('No wallets available to receive funds.'); + }); + + it('shows coin-specific message for noWalletsAbleToBuy with coin', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('noWalletsAbleToBuy', 'btc')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No wallets'); + // message template receives coin + expect(payload.message).toBeDefined(); + }); + + it('shows correct message for noWalletsAbleToSell without coin', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('noWalletsAbleToSell')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No wallets'); + expect(payload.message).toContain('sell crypto'); + }); + + it('shows correct message for noWalletsAbleToSell with coin', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('noWalletsAbleToSell', 'eth')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No wallets'); + }); + + it('shows correct message for keysNoSupportedWallet without coin', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('keysNoSupportedWallet')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No supported wallets'); + expect(payload.message).toContain('supported wallets able to buy crypto'); + }); + + it('shows correct message for keysNoSupportedWallet with coin', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('keysNoSupportedWallet', 'eth')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No supported wallets'); + }); + + it('shows correct message for keysNoSupportedWalletToSell without coin', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('keysNoSupportedWalletToSell')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No supported wallets'); + expect(payload.message).toContain('sell crypto'); + }); + + it('shows correct message for emptyKeyList', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('emptyKeyList')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No keys with supported wallets'); + expect(payload.message).toContain('receive funds'); + }); + + it('shows correct message for emptyKeyListToSend', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('emptyKeyListToSend')); + const payload = mockedShow.mock.calls[0][0]; + expect(payload.title).toBe('No keys with supported wallets'); + expect(payload.message).toContain('send funds'); + }); + + it('includes an OK action that dispatches dismissBottomNotificationModal', async () => { + const store = configureTestStore({}); + await store.dispatch(showWalletError('needsBackup')); + const payload = mockedShow.mock.calls[0][0]; + expect(Array.isArray(payload.actions)).toBe(true); + const okAction = payload.actions[0]; + expect(okAction.text).toBe('OK'); + expect(okAction.primary).toBe(true); + // Calling the action should dispatch dismiss + okAction.action(); + const {dismissBottomNotificationModal} = jest.requireMock( + '../../../app/app.actions', + ); + expect(dismissBottomNotificationModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/store/wallet/effects/import/import.spec.ts b/src/store/wallet/effects/import/import.spec.ts new file mode 100644 index 0000000000..bf8f8d9abf --- /dev/null +++ b/src/store/wallet/effects/import/import.spec.ts @@ -0,0 +1,422 @@ +/** + * Tests for import.ts + * + * Strategy: + * - normalizeMnemonic : exported pure function — exhaustive unit tests + * - startMigrationMMKVStorage : thunk that reads/writes AsyncStorage (mocked) + * - startMigration : thunk — test the "no directory" / "no keys" early- + * exit paths that trigger navigation without needing BWS + * - startImportFromHardwareWallet: basic validation paths (no key / bad coin) + * - getMissingVmAccounts (private) : indirectly via startImportMnemonic flow + */ + +import configureTestStore from '@test/store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + normalizeMnemonic, + startMigrationMMKVStorage, + startMigration, + startImportFromHardwareWallet, +} from './import'; + +// --------------------------------------------------------------------------- +// Mocks – must appear before any imports that trigger module evaluation +// --------------------------------------------------------------------------- + +jest.mock('../../../../managers/LogManager', () => ({ + logManager: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../../../../managers/TokenManager', () => ({ + tokenManager: { + getTokenOptions: jest.fn(() => ({tokenOptionsByAddress: {}})), + }, +})); + +// mock helper-methods fully to avoid address.ts → BwcProvider chain at module load +jest.mock('../../../../utils/helper-methods', () => ({ + getLastDayTimestampStartOfHourMs: jest.fn(() => Date.now() - 86400000), + addTokenChainSuffix: jest.fn( + (addr: string, chain: string) => `${addr}_e.${chain}`, + ), + getErrorString: jest.fn((e: unknown) => String(e)), + createWalletsForAccounts: jest.fn(() => Promise.resolve([])), + getEvmGasWallets: jest.fn(() => []), + checkEncryptedKeysForEddsaMigration: jest.fn(() => () => Promise.resolve()), + hashPinLegacy: jest.fn((arr: string[]) => arr.join('')), +})); + +// RNFS – already mocked in test/setup.js (all methods are jest.fn()) +// We add exists / readDir / readFile implementations per test +const RNFS = require('react-native-fs'); + +// react-native-restart +jest.mock('react-native-restart', () => ({restart: jest.fn()})); + +// navigationRef +jest.mock('../../../../Root', () => ({ + navigationRef: {navigate: jest.fn(), dispatch: jest.fn()}, +})); + +// StackActions +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + StackActions: {replace: jest.fn(() => ({type: 'STACK_REPLACE'}))}, + }; +}); + +// BwcProvider – heavy native module +jest.mock('../../../../lib/bwc', () => ({ + BwcProvider: { + getInstance: jest.fn(() => ({ + getConstants: jest.fn(() => ({})), + createKey: jest.fn(), + getClient: jest.fn(), + getBitcore: jest.fn(() => ({})), + getBitcoreCash: jest.fn(() => ({})), + getBitcoreDoge: jest.fn(() => ({})), + getBitcoreLtc: jest.fn(() => ({})), + getCore: jest.fn(() => ({})), + })), + API: {serverAssistedImport: jest.fn()}, + }, +})); + +// Status effects (heavy) +jest.mock('../status/status', () => ({ + startUpdateAllKeyAndWalletStatus: jest.fn(() => () => Promise.resolve()), +})); + +// Rates effects +jest.mock('../rates/rates', () => ({ + startGetRates: jest.fn(() => () => Promise.resolve({})), +})); + +// App effects used by migration +jest.mock('../../../app/app.effects', () => ({ + checkNotificationsPermissions: jest.fn(() => Promise.resolve(false)), + setNotifications: jest.fn(() => ({type: 'SET_NOTIFICATIONS'})), + subscribePushNotifications: jest.fn(() => () => Promise.resolve()), + subscribeEmailNotifications: jest.fn(() => () => Promise.resolve()), +})); + +// Coinbase effects +jest.mock('../../../coinbase', () => ({ + accessTokenSuccess: jest.fn(() => ({type: 'COINBASE/ACCESS_TOKEN_SUCCESS'})), + coinbaseGetAccountsAndBalance: jest.fn(() => () => Promise.resolve()), + coinbaseGetUser: jest.fn(() => () => Promise.resolve()), +})); +jest.mock('../../../coinbase/coinbase.effects', () => ({ + coinbaseUpdateExchangeRate: jest.fn(() => () => Promise.resolve()), +})); + +// Wallet utils – fully mocked to avoid deep transitive imports +jest.mock('../../utils/wallet', () => ({ + buildKeyObj: jest.fn(({key, wallets}) => ({ + id: 'test-key-id', + wallets, + methods: key, + })), + buildMigrationKeyObj: jest.fn(() => ({id: 'migrated-key', wallets: []})), + buildWalletObj: jest.fn((cred: any) => cred), + findMatchedKeyAndUpdate: jest.fn(() => ({ + key: {id: 'k1'}, + wallets: [], + keyName: undefined, + })), + getMatchedKey: jest.fn(() => null), + getReadOnlyKey: jest.fn(() => null), + isMatch: jest.fn(() => false), + isMatchedWallet: jest.fn(() => false), + mapAbbreviationAndName: jest.fn(() => () => ({ + currencyAbbreviation: 'btc', + currencyName: 'Bitcoin', + })), + findKeyByKeyId: jest.fn(() => ({id: 'k1', wallets: []})), + isCacheKeyStale: jest.fn(() => true), +})); + +// wallet-hardware utils +jest.mock('../../../../utils/wallet-hardware', () => ({ + credentialsFromExtendedPublicKey: jest.fn(() => + JSON.stringify({ + walletId: 'test-wallet-id', + walletPrivKey: 'test-priv-key', + }), + ), +})); + +// currency utils +jest.mock('../../utils/currency', () => ({ + GetName: jest.fn(() => () => 'Bitcoin'), + IsSegwitCoin: jest.fn(() => false), + IsERCToken: jest.fn(() => false), + isSingleAddressChain: jest.fn(() => true), +})); + +// MMKV – mock the native module so that `storage = new MMKV()` in store/index.ts works +jest.mock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => ({ + getString: jest.fn(() => null), + set: jest.fn(), + delete: jest.fn(), + getAllKeys: jest.fn(() => []), + contains: jest.fn(() => false), + })), +})); + +// --------------------------------------------------------------------------- +// normalizeMnemonic – pure function tests +// --------------------------------------------------------------------------- +describe('normalizeMnemonic', () => { + it('returns undefined when words is undefined', () => { + expect(normalizeMnemonic(undefined)).toBeUndefined(); + }); + + it('returns the empty string when words is an empty string', () => { + // normalizeMnemonic checks `!words` — empty string is falsy, returns early with the input + expect(normalizeMnemonic('')).toBe(''); + }); + + it('converts uppercase words to lowercase', () => { + const result = normalizeMnemonic('ABANDON ABILITY ABLE'); + expect(result).toBe('abandon ability able'); + }); + + it('trims leading and trailing whitespace', () => { + const result = normalizeMnemonic(' abandon ability '); + expect(result).toBe('abandon ability'); + }); + + it('collapses multiple spaces between words into one', () => { + const result = normalizeMnemonic('abandon ability able'); + expect(result).toBe('abandon ability able'); + }); + + it('handles tab characters as word separators', () => { + const result = normalizeMnemonic('abandon\tability\table'); + expect(result).toBe('abandon ability able'); + }); + + it('handles newline characters as word separators', () => { + const result = normalizeMnemonic('abandon\nability\nable'); + expect(result).toBe('abandon ability able'); + }); + + it('handles Japanese ideographic spaces (U+3000) and joins with U+3000', () => { + const JA_SPACE = '\u3000'; + const result = normalizeMnemonic( + `\u3042\u3070\u3093${JA_SPACE}\u304f\u308b\u307e${JA_SPACE}\u3048\u3093`, + ); + expect(result).toContain(JA_SPACE); + }); + + it('returns the same single word trimmed and lowercased', () => { + expect(normalizeMnemonic(' ABANDON ')).toBe('abandon'); + }); + + it('preserves normal 12-word mnemonic with single spaces', () => { + const mnemonic = + 'abandon ability able about above absent absorb abstract absurd abuse access accident'; + expect(normalizeMnemonic(mnemonic)).toBe(mnemonic); + }); + + it('normalizes a 12-word mnemonic with mixed case and extra spaces', () => { + const input = + ' ABANDON ABILITY ABLE ABOUT ABOVE ABSENT ABSORB ABSTRACT ABSURD ABUSE ACCESS ACCIDENT '; + const expected = + 'abandon ability able about above absent absorb abstract absurd abuse access accident'; + expect(normalizeMnemonic(input)).toBe(expected); + }); + + it('handles a string that has no spaces gracefully (single-word passphrase)', () => { + expect(normalizeMnemonic('singleword')).toBe('singleword'); + }); + + it('returns words when the input has no indexOf method (e.g. number-like)', () => { + // normalizeMnemonic guards: if (!words.indexOf) return words + const fakeWords: any = 12345; + expect(normalizeMnemonic(fakeWords)).toBe(12345); + }); +}); + +// --------------------------------------------------------------------------- +// startMigrationMMKVStorage – AsyncStorage-backed thunk +// --------------------------------------------------------------------------- +describe('startMigrationMMKVStorage', () => { + const {storage} = require('../../../index'); + const RNRestart = require('react-native-restart'); + + beforeEach(() => { + jest.clearAllMocks(); + (AsyncStorage.getAllKeys as jest.Mock).mockResolvedValue([]); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + (AsyncStorage.multiRemove as jest.Mock).mockResolvedValue(undefined); + }); + + it('does not restart when persist:root key is absent from AsyncStorage', async () => { + (AsyncStorage.getAllKeys as jest.Mock).mockResolvedValue([ + 'some-other-key', + ]); + const store = configureTestStore({}); + await store.dispatch(startMigrationMMKVStorage()); + expect(RNRestart.restart).not.toHaveBeenCalled(); + }); + + it('sets migrationMMKVStorageComplete=true in state when persist:root is not in AsyncStorage', async () => { + (AsyncStorage.getAllKeys as jest.Mock).mockResolvedValue([]); + const store = configureTestStore({}); + await store.dispatch(startMigrationMMKVStorage()); + // The action should be reflected in the APP state + const appState = (store.getState() as any).APP; + expect(appState.migrationMMKVStorageComplete).toBe(true); + }); + + it('calls storage.set and RNRestart.restart when persist:root exists', async () => { + (AsyncStorage.getAllKeys as jest.Mock).mockResolvedValue([ + 'persist:root', + 'other', + ]); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('{"APP":{}}'); + const store = configureTestStore({}); + await store.dispatch(startMigrationMMKVStorage()); + expect(storage.set).toHaveBeenCalledWith('persist:root', '{"APP":{}}'); + expect(RNRestart.restart).toHaveBeenCalledTimes(1); + }); + + it('does not call storage.set when persist:root value is null', async () => { + (AsyncStorage.getAllKeys as jest.Mock).mockResolvedValue(['persist:root']); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + const store = configureTestStore({}); + await store.dispatch(startMigrationMMKVStorage()); + expect(storage.set).not.toHaveBeenCalled(); + }); + + it('handles AsyncStorage.getAllKeys rejection gracefully (no throw)', async () => { + (AsyncStorage.getAllKeys as jest.Mock).mockRejectedValueOnce( + new Error('storage error'), + ); + const store = configureTestStore({}); + // Should not throw + await expect( + store.dispatch(startMigrationMMKVStorage()), + ).resolves.not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// startMigration – early-exit paths that don't need real BWS data +// --------------------------------------------------------------------------- +describe('startMigration – early-exit paths', () => { + const {navigationRef} = require('../../../../Root'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to OnboardingStart when cordova directory does not exist', async () => { + (RNFS.exists as jest.Mock).mockResolvedValueOnce(false); + const store = configureTestStore({}); + await store.dispatch(startMigration()); + expect(navigationRef.dispatch).toHaveBeenCalled(); + }); + + it('navigates to OnboardingStart when key file is not found in directory', async () => { + (RNFS.exists as jest.Mock).mockResolvedValueOnce(true); + // readDir returns a list without a 'keys' file + (RNFS.readDir as jest.Mock).mockResolvedValueOnce([{name: 'profile'}]); + const store = configureTestStore({}); + await store.dispatch(startMigration()); + expect(navigationRef.dispatch).toHaveBeenCalled(); + }); + + it('navigates to OnboardingStart when keys file is empty array', async () => { + (RNFS.exists as jest.Mock).mockResolvedValueOnce(true); + (RNFS.readDir as jest.Mock).mockResolvedValueOnce([ + {name: 'keys'}, + {name: 'profile'}, + ]); + (RNFS.readFile as jest.Mock) + .mockResolvedValueOnce('[]') // keys file → empty array + .mockResolvedValueOnce('{"credentials":[]}'); // profile file + const store = configureTestStore({}); + await store.dispatch(startMigration()); + expect(navigationRef.dispatch).toHaveBeenCalled(); + }); + + it('resolves without throwing even when RNFS.exists rejects', async () => { + (RNFS.exists as jest.Mock).mockRejectedValueOnce(new Error('fs error')); + const store = configureTestStore({}); + // startMigration catches errors and resolves + await expect(store.dispatch(startMigration())).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// startImportFromHardwareWallet – validation paths (no real BWS calls) +// --------------------------------------------------------------------------- +describe('startImportFromHardwareWallet – validation paths', () => { + const baseArgs = { + key: { + id: 'hw-key-id', + wallets: [], + } as any, + hardwareSource: 'ledger' as any, + xPubKey: 'xpub-test', + accountPath: "m/44'/0'/0'", + coin: 'btc' as const, + chain: 'btc' as const, + derivationStrategy: 'BIP44', + accountNumber: 0, + network: 'livenet' as any, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects when hardwareSource is falsy', async () => { + const store = configureTestStore({}); + const args = {...baseArgs, hardwareSource: '' as any}; + await expect( + store.dispatch(startImportFromHardwareWallet(args)), + ).rejects.toThrow('Invalid hardware wallet source'); + }); + + it('rejects when coin is not in BitpaySupportedCoins', async () => { + const store = configureTestStore({}); + const args = {...baseArgs, coin: 'INVALIDCOIN' as any}; + await expect( + store.dispatch(startImportFromHardwareWallet(args)), + ).rejects.toThrow('Unsupported currency'); + }); + + it('rejects when the wallet already exists in the key', async () => { + const store = configureTestStore({}); + const existingWallet = { + credentials: { + rootPath: "m/44'/0'/0'", + account: 0, + network: 'livenet', + coin: 'btc', + chain: 'btc', + }, + currencyAbbreviation: 'btc', + chain: 'btc', + }; + const args = { + ...baseArgs, + key: {id: 'hw-key-id', wallets: [existingWallet]} as any, + }; + await expect( + store.dispatch(startImportFromHardwareWallet(args)), + ).rejects.toThrow('already in the app'); + }); +}); diff --git a/src/store/wallet/effects/rates/rates.spec.ts b/src/store/wallet/effects/rates/rates.spec.ts new file mode 100644 index 0000000000..0fbc292ddc --- /dev/null +++ b/src/store/wallet/effects/rates/rates.spec.ts @@ -0,0 +1,1687 @@ +/** + * Tests for rates.ts + * + * Strategy: + * - The file exports several Redux Effect thunks plus a few standalone async + * helpers. Many internal helpers are private (not exported), but their + * behaviour surfaces through the exported functions we *can* call. + * - We test the exported pure-ish helpers: + * getHistoricFiatRate – wraps axios.get, no store needed + * startGetRates – dispatches, uses cached vs. fresh path + * refreshFiatRateSeries – pure cache-append logic via store + * fetchFiatRateSeriesInterval – cache-hit short-circuit path + * - We also exercise the rate.models helpers imported by rates.ts: + * getFiatRateSeriesCacheKey + * hasValidSeriesForCoin + */ + +import axios from 'axios'; +import configureTestStore from '@test/store'; +import { + getHistoricFiatRate, + startGetRates, + refreshFiatRateSeries, + fetchFiatRateSeriesInterval, + fetchFiatRateSeriesAllIntervals, + getContractAddresses, + getTokenRates, +} from './rates'; +import { + getFiatRateSeriesCacheKey, + hasValidSeriesForCoin, +} from '../../../rate/rate.models'; + +// --------------------------------------------------------------------------- +// Module-level mocks +// --------------------------------------------------------------------------- + +jest.mock('../../../../managers/LogManager', () => ({ + logManager: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../../../../managers/TokenManager', () => ({ + tokenManager: { + getTokenOptions: jest.fn(() => ({tokenOptionsByAddress: {}})), + }, +})); + +// Moralis token prices – return empty array by default so getTokenRates is a no-op +jest.mock('../../../../store/moralis/moralis.effects', () => ({ + getMultipleTokenPrices: jest.fn(() => () => Promise.resolve([])), +})); + +// buy-crypto effect used for token rate conversion +jest.mock('../../../../store/buy-crypto/buy-crypto.effects', () => ({ + calculateUsdToAltFiat: jest.fn(() => () => 0), +})); + +// Status effect (heavy) +jest.mock('../status/status', () => ({ + startUpdateAllKeyAndWalletStatus: jest.fn(() => () => Promise.resolve()), +})); + +// Mock helper-methods to avoid the address.ts → BwcProvider.getBitcore() chain +jest.mock('../../../../utils/helper-methods', () => ({ + ...jest.requireActual('../../../../utils/helper-methods'), + getLastDayTimestampStartOfHourMs: jest.fn(() => Date.now() - 86400000), + addTokenChainSuffix: jest.fn( + (addr: string, chain: string) => `${addr}_e.${chain}`, + ), + getErrorString: jest.fn((e: unknown) => String(e)), + createWalletsForAccounts: jest.fn(() => Promise.resolve([])), + getEvmGasWallets: jest.fn(() => []), + checkEncryptedKeysForEddsaMigration: jest.fn(() => () => Promise.resolve()), + isL2NoSideChainNetwork: jest.fn(() => false), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const NOW = 1_700_000_000_000; // fixed "now" for cache staleness tests + +/** Build a minimal Redux state with fresh rate-series cache for one coin. */ +const buildStateWithCache = ( + coin: string, + interval: string, + points: {ts: number; rate: number}[], + fetchedOn: number = NOW, +) => { + const cacheKey = getFiatRateSeriesCacheKey('USD', coin, interval as any); + return { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: { + [cacheKey]: {fetchedOn, points}, + }, + ratesCacheKey: {}, + }, + }; +}; + +// --------------------------------------------------------------------------- +// getFiatRateSeriesCacheKey (pure – from rate.models) +// --------------------------------------------------------------------------- +describe('getFiatRateSeriesCacheKey', () => { + it('returns upper-case fiat code, lower-case coin, and interval', () => { + expect(getFiatRateSeriesCacheKey('usd', 'BTC', '1D')).toBe('USD:btc:1D'); + }); + + it('normalizes empty fiat code to empty string prefix', () => { + const key = getFiatRateSeriesCacheKey('', 'eth', '1W'); + expect(key).toBe(':eth:1W'); + }); + + it('normalizes empty coin to empty string segment', () => { + const key = getFiatRateSeriesCacheKey('USD', '', '1M'); + expect(key).toBe('USD::1M'); + }); + + it('is consistent across multiple calls with same args', () => { + const k1 = getFiatRateSeriesCacheKey('USD', 'btc', 'ALL'); + const k2 = getFiatRateSeriesCacheKey('USD', 'btc', 'ALL'); + expect(k1).toBe(k2); + }); + + it('produces different keys for different intervals', () => { + const k1D = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const k1W = getFiatRateSeriesCacheKey('USD', 'btc', '1W'); + expect(k1D).not.toBe(k1W); + }); + + it('produces different keys for different coins', () => { + const kBtc = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const kEth = getFiatRateSeriesCacheKey('USD', 'eth', '1D'); + expect(kBtc).not.toBe(kEth); + }); + + it('produces different keys for different fiat codes', () => { + const kUsd = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const kEur = getFiatRateSeriesCacheKey('EUR', 'btc', '1D'); + expect(kUsd).not.toBe(kEur); + }); +}); + +// --------------------------------------------------------------------------- +// hasValidSeriesForCoin (pure – from rate.models) +// --------------------------------------------------------------------------- +describe('hasValidSeriesForCoin', () => { + it('returns false when cache is undefined', () => { + expect( + hasValidSeriesForCoin({ + cache: undefined, + fiatCodeUpper: 'USD', + normalizedCoin: 'btc', + intervals: ['1D'], + }), + ).toBe(false); + }); + + it('returns false when cache is empty object', () => { + expect( + hasValidSeriesForCoin({ + cache: {}, + fiatCodeUpper: 'USD', + normalizedCoin: 'btc', + intervals: ['1D'], + }), + ).toBe(false); + }); + + it('returns true when cache has valid points for the requested interval', () => { + const cacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + expect( + hasValidSeriesForCoin({ + cache: {[cacheKey]: {fetchedOn: NOW, points: [{ts: 1, rate: 30000}]}}, + fiatCodeUpper: 'USD', + normalizedCoin: 'btc', + intervals: ['1D'], + }), + ).toBe(true); + }); + + it('returns false when one of multiple required intervals is missing', () => { + const key1D = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + expect( + hasValidSeriesForCoin({ + cache: {[key1D]: {fetchedOn: NOW, points: [{ts: 1, rate: 30000}]}}, + fiatCodeUpper: 'USD', + normalizedCoin: 'btc', + intervals: ['1D', '1W'], + }), + ).toBe(false); + }); + + it('returns false when points array is empty', () => { + const cacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + expect( + hasValidSeriesForCoin({ + cache: {[cacheKey]: {fetchedOn: NOW, points: []}}, + fiatCodeUpper: 'USD', + normalizedCoin: 'btc', + intervals: ['1D'], + }), + ).toBe(false); + }); + + it('returns false when a point has a non-finite ts', () => { + const cacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + expect( + hasValidSeriesForCoin({ + cache: { + [cacheKey]: { + fetchedOn: NOW, + points: [{ts: NaN, rate: 30000}], + }, + }, + fiatCodeUpper: 'USD', + normalizedCoin: 'btc', + intervals: ['1D'], + }), + ).toBe(false); + }); + + it('returns false when fiatCodeUpper is empty', () => { + expect( + hasValidSeriesForCoin({ + cache: {}, + fiatCodeUpper: '', + normalizedCoin: 'btc', + intervals: ['1D'], + }), + ).toBe(false); + }); + + it('returns false when normalizedCoin is empty', () => { + expect( + hasValidSeriesForCoin({ + cache: {}, + fiatCodeUpper: 'USD', + normalizedCoin: '', + intervals: ['1D'], + }), + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getHistoricFiatRate – wraps axios.get +// --------------------------------------------------------------------------- +describe('getHistoricFiatRate', () => { + const mockedAxios = axios as jest.Mocked; + + beforeEach(() => jest.clearAllMocks()); + + it('resolves with data returned by axios', async () => { + const mockRate = {fetchedOn: NOW, rate: 30000, ts: NOW}; + mockedAxios.get.mockResolvedValueOnce({data: mockRate}); + + const result = await getHistoricFiatRate('USD', 'btc', String(NOW)); + expect(result).toEqual(mockRate); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('btc'), + ); + }); + + it('rejects when axios throws', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('network error')); + await expect( + getHistoricFiatRate('USD', 'btc', String(NOW)), + ).rejects.toThrow('network error'); + }); + + it('includes the fiatCode in the URL', async () => { + mockedAxios.get.mockResolvedValueOnce({data: {}}); + await getHistoricFiatRate('EUR', 'eth', '12345'); + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('EUR'), + ); + }); + + it('includes the coin in the URL', async () => { + mockedAxios.get.mockResolvedValueOnce({data: {}}); + await getHistoricFiatRate('USD', 'ltc', '12345'); + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('ltc'), + ); + }); + + it('includes the timestamp in the URL', async () => { + mockedAxios.get.mockResolvedValueOnce({data: {}}); + await getHistoricFiatRate('USD', 'btc', '999888777'); + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('999888777'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// startGetRates – cache-hit path (no HTTP calls) +// --------------------------------------------------------------------------- +describe('startGetRates – cache-hit path', () => { + const mockedAxios = axios as jest.Mocked; + + beforeEach(() => jest.clearAllMocks()); + + it('returns cached rates without making HTTP calls when cache is fresh and altCurrencyList populated', async () => { + const RATES_CACHE_DURATION = 5 * 60 * 1000; // 5 min + const freshTimestamp = Date.now() - 1000; // 1 second ago – well within duration + + const cachedRates = { + btc: [ + { + code: 'USD', + rate: 50000, + name: 'Bitcoin', + fetchedOn: freshTimestamp, + ts: freshTimestamp, + }, + ], + }; + const state = { + RATE: { + rates: cachedRates, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: { + 1: freshTimestamp, + }, + }, + APP: { + altCurrencyList: [{isoCode: 'USD', name: 'US Dollar'}], + }, + }; + + const store = configureTestStore(state); + const result = await store.dispatch(startGetRates({force: false})); + + // Cache hit – axios should NOT be called + expect(mockedAxios.get).not.toHaveBeenCalled(); + expect(result).toEqual(cachedRates); + }); +}); + +// --------------------------------------------------------------------------- +// startGetRates – force fetch path +// --------------------------------------------------------------------------- +describe('startGetRates – force fetch path', () => { + const mockedAxios = axios as jest.Mocked; + + beforeEach(() => jest.clearAllMocks()); + + it('fetches rates from network when force=true', async () => { + const freshRates = { + btc: [ + {code: 'USD', rate: 60000, name: 'Bitcoin', fetchedOn: NOW, ts: NOW}, + ], + }; + mockedAxios.get + .mockResolvedValueOnce({data: freshRates}) // current rates + .mockResolvedValueOnce({data: freshRates}); // yesterday rates + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + APP: {altCurrencyList: [{isoCode: 'USD', name: 'US Dollar'}]}, + WALLET: {keys: {}, customTokenOptionsByAddress: {}}, + }; + const store = configureTestStore(state); + + await store.dispatch(startGetRates({force: true})); + // Both current and yesterday endpoints should have been called + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + }); + + it('resolves with cached rates when network call fails', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('offline')); + + const cachedRates = { + btc: [ + {code: 'USD', rate: 30000, name: 'Bitcoin', fetchedOn: NOW, ts: NOW}, + ], + }; + const state = { + RATE: { + rates: cachedRates, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + APP: {altCurrencyList: []}, + WALLET: {keys: {}, customTokenOptionsByAddress: {}}, + }; + const store = configureTestStore(state); + + const result = await store.dispatch(startGetRates({force: true})); + // Should gracefully fall back to cached rates + expect(result).toEqual(cachedRates); + }); +}); + +// --------------------------------------------------------------------------- +// refreshFiatRateSeries – cache-append logic +// --------------------------------------------------------------------------- +describe('refreshFiatRateSeries', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns false when spotRate is missing', async () => { + const state = buildStateWithCache('btc', '1D', [ + {ts: NOW - 60_000, rate: 30000}, + ]); + const store = configureTestStore(state); + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1D', + spotRate: undefined, + }), + ); + expect(result).toBe(false); + }); + + it('returns false when spotRate is NaN', async () => { + const state = buildStateWithCache('btc', '1D', [ + {ts: NOW - 60_000, rate: 30000}, + ]); + const store = configureTestStore(state); + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1D', + spotRate: NaN, + }), + ); + expect(result).toBe(false); + }); + + it('returns false when there are no cached points', async () => { + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1D', + spotRate: 40000, + }), + ); + expect(result).toBe(false); + }); + + it('returns false for "ALL" interval (not refreshable)', async () => { + const state = buildStateWithCache('btc', 'ALL', [ + {ts: NOW - 100_000, rate: 30000}, + ]); + const store = configureTestStore(state); + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: 'ALL', + spotRate: 40000, + }), + ); + expect(result).toBe(false); + }); + + it('returns false when cadence threshold has not elapsed', async () => { + // 1D interval has a cadence of 15 minutes (900_000 ms) + // last point is only 1 second old → below cadence threshold + const recentTs = Date.now() - 1000; + const state = buildStateWithCache('btc', '1D', [ + {ts: recentTs, rate: 30000}, + ]); + const store = configureTestStore(state); + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1D', + spotRate: 35000, + }), + ); + expect(result).toBe(false); + }); + + it('appends a new point and returns true when cadence has elapsed', async () => { + // 1D cadence = 15 min. Last point is 20 min old → should refresh. + // Use 2 initial points so the window-slice logic keeps both + new. + const oldTs1 = Date.now() - 40 * 60 * 1000; + const oldTs2 = Date.now() - 20 * 60 * 1000; + const state = buildStateWithCache('btc', '1D', [ + {ts: oldTs1, rate: 29000}, + {ts: oldTs2, rate: 30000}, + ]); + const store = configureTestStore(state); + + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1D', + spotRate: 35000, + }), + ); + expect(result).toBe(true); + + // Verify the new point was stored in the cache (length stays at targetLength = 2) + const cacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const updatedCache = store.getState().RATE?.fiatRateSeriesCache; + const updatedSeries = updatedCache?.[cacheKey]; + // The series length is preserved (window slide), and the new rate is present + expect(updatedSeries?.points.length).toBeGreaterThanOrEqual(1); + const rates = updatedSeries?.points.map(p => p.rate) ?? []; + expect(rates).toContain(35000); + }); + + it('handles matic/pol normalization correctly', async () => { + // 'matic' normalizes to 'pol' in normalizeFiatRateSeriesCoin + const oldTs = Date.now() - 20 * 60 * 1000; + // Cache stored under 'pol' key + const state = buildStateWithCache('pol', '1D', [{ts: oldTs, rate: 0.8}]); + const store = configureTestStore(state); + + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'matic', // normalized to 'pol' + interval: '1D', + spotRate: 0.9, + }), + ); + expect(result).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// fetchFiatRateSeriesInterval – cache-hit short-circuit +// --------------------------------------------------------------------------- +describe('fetchFiatRateSeriesInterval – cache-hit path', () => { + const mockedAxios = axios as jest.Mocked; + + beforeEach(() => jest.clearAllMocks()); + + it('returns true without making a network call when cache is fresh', async () => { + // Cache duration is 15 min; fetched 1 min ago → fresh + const freshFetchedOn = Date.now() - 60_000; + const state = buildStateWithCache( + 'btc', + '1D', + [{ts: Date.now() - 500, rate: 30000}], + freshFetchedOn, + ); + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: false, + }), + ); + + expect(result).toBe(true); + expect(mockedAxios.get).not.toHaveBeenCalled(); + }); + + it('hits the network when force=true even if cache is fresh', async () => { + const freshFetchedOn = Date.now() - 60_000; + const state = buildStateWithCache( + 'btc', + '1D', + [{ts: Date.now() - 500, rate: 30000}], + freshFetchedOn, + ); + const store = configureTestStore(state); + + // Provide a mock response for the network call + const mockResponseData = { + btc: [{ts: Date.now(), rate: 31000}], + }; + mockedAxios.get.mockResolvedValueOnce({data: mockResponseData}); + + await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: true, + }), + ); + + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + }); + + it('returns false when network call fails', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('network error')); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: false, + }), + ); + + expect(result).toBe(false); + }); + + it('stores fetched series in cache on success', async () => { + const ts = Date.now(); + mockedAxios.get.mockResolvedValueOnce({ + data: { + btc: [ + {ts: ts - 1000, rate: 40000}, + {ts, rate: 41000}, + ], + }, + }); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + coin: 'btc', + force: false, + }), + ); + + expect(result).toBe(true); + const cacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const storedSeries = store.getState().RATE?.fiatRateSeriesCache?.[cacheKey]; + expect(storedSeries?.points?.length).toBeGreaterThan(0); + }); + + it('returns false when response data has no keys (empty object shape)', async () => { + // coerceV4FiatRatesPayloadToByCoin returns {} → requestFailed = true + mockedAxios.get.mockResolvedValueOnce({data: {}}); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: false, + }), + ); + + expect(result).toBe(false); + }); + + it('returns false when axios response is null/non-object (invalid shape)', async () => { + mockedAxios.get.mockResolvedValueOnce({data: null}); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: false, + }), + ); + + expect(result).toBe(false); + }); + + it('handles array payload by mapping to requested coin', async () => { + const ts = Date.now(); + // An array response is coerced to {[coin]: array} + mockedAxios.get.mockResolvedValueOnce({ + data: [ + {ts: ts - 1000, rate: 45000}, + {ts, rate: 46000}, + ], + }); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + coin: 'btc', + force: false, + }), + ); + + expect(result).toBe(true); + }); + + it('handles array payload without a coin param → returns false (no coin to map to)', async () => { + const ts = Date.now(); + // Array without a specific coin means coerceV4FiatRatesPayloadToByCoin returns {} + mockedAxios.get.mockResolvedValueOnce({ + data: [ + {ts: ts - 1000, rate: 45000}, + {ts, rate: 46000}, + ], + }); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + // No coin param — array payload coerces to {} → requestFailed = true + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: false, + }), + ); + + expect(result).toBe(false); + }); + + it('triggers coin-specific fallback when default response missing coin (no allowedCoins restriction)', async () => { + const ts = Date.now(); + // First call returns eth data but NOT eth in coinForCacheCheck='eth' + // after the default request, hasTargetCoinSeries will be false → fallback coin fetch + mockedAxios.get + .mockResolvedValueOnce({ + data: { + btc: [ + {ts: ts - 1000, rate: 40000}, + {ts, rate: 41000}, + ], + }, + }) // default fetch (no coin param) + .mockResolvedValueOnce({ + data: [ + {ts: ts - 500, rate: 2000}, + {ts: ts, rate: 2100}, + ], + }); // coin-specific fetch for eth + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'eth', + force: false, + // No coin param → default v4 request. eth missing → triggers coin fallback + }), + ); + + // The fallback may succeed or fail based on mock response shape; either way no throw + expect(typeof result).toBe('boolean'); + }); + + it('uses stale cache when force=false and cache is stale', async () => { + const ts = Date.now(); + // Stale cache: fetchedOn far in the past + const staleFetchedOn = Date.now() - 60 * 60 * 1000; // 1 hour ago (beyond HISTORIC_RATES_CACHE_DURATION) + + const state = buildStateWithCache( + 'btc', + '1D', + [{ts: ts - 1000, rate: 30000}], + staleFetchedOn, + ); + const store = configureTestStore(state); + + // Stale cache → should hit network + mockedAxios.get.mockResolvedValueOnce({ + data: { + btc: [ + {ts: ts - 500, rate: 32000}, + {ts, rate: 33000}, + ], + }, + }); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: false, + }), + ); + + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(result).toBe(true); + }); + + it('filters out coins not in allowedCoins set', async () => { + const ts = Date.now(); + // Response includes both eth and btc but allowedCoins only allows btc + mockedAxios.get.mockResolvedValueOnce({ + data: { + btc: [ + {ts: ts - 1000, rate: 40000}, + {ts, rate: 41000}, + ], + eth: [ + {ts: ts - 1000, rate: 2000}, + {ts, rate: 2100}, + ], + }, + }); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + allowedCoins: ['btc'], + force: false, + }), + ); + + // Only btc should be in cache (eth filtered out) + const ethCacheKey = getFiatRateSeriesCacheKey('USD', 'eth', '1D'); + const btcCacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const cacheState = store.getState().RATE?.fiatRateSeriesCache || {}; + expect(cacheState[ethCacheKey]).toBeUndefined(); + expect(cacheState[btcCacheKey]).toBeDefined(); + }); + + it('handles points with non-finite ts/rate in raw response gracefully', async () => { + const ts = Date.now(); + // Response contains some invalid points mixed in with valid ones + // 'invalid' string → Number('invalid') = NaN (not finite) → filtered + // undefined → Number(undefined) = NaN → filtered + // NaN rate → filtered by isFinite(rate) + mockedAxios.get.mockResolvedValueOnce({ + data: { + btc: [ + {ts: 'invalid', rate: 40000}, // invalid ts string + {ts: undefined, rate: 40000}, // undefined ts + {ts: ts - 1000, rate: NaN}, // invalid rate + {ts: ts, rate: 41000}, // only valid point + ], + }, + }); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + coin: 'btc', + force: false, + }), + ); + + // The valid point(s) should be stored + const cacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const stored = store.getState().RATE?.fiatRateSeriesCache?.[cacheKey]; + expect(result).toBe(true); + expect(stored?.points?.length).toBeGreaterThanOrEqual(1); + const rates = stored?.points?.map((p: any) => p.rate) ?? []; + expect(rates).toContain(41000); + }); + + it('returns false when response has only invalid points (no valid deduped points)', async () => { + // All points have invalid ts → sanitizeSortDedupePoints returns [] → no update + mockedAxios.get.mockResolvedValueOnce({ + data: { + btc: [ + {ts: 'bad', rate: 40000}, + {ts: undefined, rate: 40000}, + ], + }, + }); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + // coin param makes updateCount=0 → false + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + coin: 'btc', + force: false, + }), + ); + + expect(result).toBe(false); + }); + + it('handles ALL interval (no days param) correctly', async () => { + const ts = Date.now(); + mockedAxios.get.mockResolvedValueOnce({ + data: { + btc: [ + {ts: ts - 86400000, rate: 30000}, + {ts, rate: 40000}, + ], + }, + }); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: 'ALL', + coinForCacheCheck: 'btc', + coin: 'btc', + force: false, + }), + ); + + expect(result).toBe(true); + // URL should NOT contain days param + const calledUrl = (mockedAxios.get as jest.Mock).mock.calls[0][0]; + expect(calledUrl).not.toContain('days='); + }); + + it('handles axios error with string response body', async () => { + const axiosError = { + isAxiosError: true, + response: {data: 'Service Unavailable'}, + message: '503 error', + }; + (axios.isAxiosError as unknown as jest.Mock) = jest.fn(() => true); + mockedAxios.get.mockRejectedValueOnce(axiosError); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: false, + }), + ); + + expect(result).toBe(false); + }); + + it('handles axios error with object response body (error field)', async () => { + const axiosError = { + isAxiosError: true, + response: {data: {error: 'Not found'}}, + message: '404 error', + }; + (axios.isAxiosError as unknown as jest.Mock) = jest.fn(() => true); + mockedAxios.get.mockRejectedValueOnce(axiosError); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: false, + }), + ); + + expect(result).toBe(false); + }); + + it('handles non-axios error gracefully', async () => { + (axios.isAxiosError as unknown as jest.Mock) = jest.fn(() => false); + mockedAxios.get.mockRejectedValueOnce(new Error('unexpected')); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: 'USD', + interval: '1D', + coinForCacheCheck: 'btc', + force: false, + }), + ); + + expect(result).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// fetchFiatRateSeriesAllIntervals – higher-level orchestration +// --------------------------------------------------------------------------- +describe('fetchFiatRateSeriesAllIntervals', () => { + const mockedAxios = axios as jest.Mocked; + + beforeEach(() => jest.clearAllMocks()); + + const buildFreshCacheForAllIntervals = (coin: string) => { + const freshFetchedOn = Date.now() - 60_000; // 1 min ago → fresh + const ts = Date.now(); + const cache: Record< + string, + {fetchedOn: number; points: {ts: number; rate: number}[]} + > = {}; + for (const interval of ['1D', '1W', '1M', '3M', '1Y', '5Y', 'ALL']) { + const key = getFiatRateSeriesCacheKey('USD', coin, interval as any); + cache[key] = { + fetchedOn: freshFetchedOn, + points: [ + {ts: ts - 1000, rate: 40000}, + {ts, rate: 41000}, + ], + }; + } + return cache; + }; + + it('skips coin-specific fetches when coinForCacheCheck is BTC (already handled by default request)', async () => { + // All intervals are fresh for btc in cache + const freshCache = buildFreshCacheForAllIntervals('btc'); + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: freshCache, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + await store.dispatch( + fetchFiatRateSeriesAllIntervals({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + force: false, + }), + ); + + // Fresh cache → no network calls + expect(mockedAxios.get).not.toHaveBeenCalled(); + }); + + it('returns early when coinForCacheCheck is empty string', async () => { + mockedAxios.get.mockResolvedValue({data: {}}); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + // Empty currencyAbbreviation → normalizeFiatRateSeriesCoin('') = '' + // fetchFiatRateSeriesAllIntervals returns early after default requests + await store.dispatch( + fetchFiatRateSeriesAllIntervals({ + fiatCode: 'USD', + currencyAbbreviation: '', + force: false, + }), + ); + + // Should have attempted default BTC fetches but not coin-specific fetches + // (returns early when coinForCacheCheck is empty) + expect(typeof mockedAxios.get).toBe('function'); // no throw, resolved normally + }); + + it('skips coin-specific fetch when coin is not in allowedCoins set', async () => { + const ts = Date.now(); + // Default requests for btc will go out + mockedAxios.get.mockResolvedValue({ + data: { + btc: [ + {ts: ts - 1000, rate: 40000}, + {ts, rate: 41000}, + ], + }, + }); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + // eth is the requested coin but allowedCoins only has btc (+ btc always added) + await store.dispatch( + fetchFiatRateSeriesAllIntervals({ + fiatCode: 'USD', + currencyAbbreviation: 'eth', + allowedCoins: ['btc'], // eth not in here + force: false, + }), + ); + + // Should not throw; returns early because eth not in allowedCoins + expect(typeof mockedAxios.get).toBe('function'); + }); + + it('fetches missing intervals for non-btc coin', async () => { + const ts = Date.now(); + // BTC default fetches return data for btc but not eth; eth interval is missing + mockedAxios.get.mockResolvedValue({ + data: { + btc: [ + {ts: ts - 1000, rate: 40000}, + {ts, rate: 41000}, + ], + }, + }); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + await store.dispatch( + fetchFiatRateSeriesAllIntervals({ + fiatCode: 'USD', + currencyAbbreviation: 'eth', + force: false, + }), + ); + + // Multiple network calls should have been made (one per interval for default + coin-specific) + expect(mockedAxios.get).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// startGetRates – init context (sets altCurrencyList) +// --------------------------------------------------------------------------- +describe('startGetRates – init context', () => { + const mockedAxios = axios as jest.Mocked; + + beforeEach(() => jest.clearAllMocks()); + + it('sets altCurrencyList when context is "init"', async () => { + const freshRates = { + btc: [ + {code: 'USD', rate: 60000, name: 'US Dollar', fetchedOn: NOW, ts: NOW}, + {code: 'EUR', rate: 55000, name: 'Euro', fetchedOn: NOW, ts: NOW}, + ], + }; + mockedAxios.get + .mockResolvedValueOnce({data: freshRates}) + .mockResolvedValueOnce({data: freshRates}); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + APP: {altCurrencyList: []}, + WALLET: {keys: {}, customTokenOptionsByAddress: {}}, + }; + const store = configureTestStore(state); + + await store.dispatch(startGetRates({context: 'init', force: true})); + + // Should have dispatched addAltCurrencyList with USD and EUR + // We verify indirectly – no throw and 2 axios calls made + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + }); + + it('updates altCurrencyList when altCurrencyList is empty (even without init context)', async () => { + const freshRates = { + btc: [ + {code: 'USD', rate: 60000, name: 'US Dollar', fetchedOn: NOW, ts: NOW}, + ], + }; + mockedAxios.get + .mockResolvedValueOnce({data: freshRates}) + .mockResolvedValueOnce({data: freshRates}); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + APP: {altCurrencyList: []}, // empty → should trigger alt currency list update + WALLET: {keys: {}, customTokenOptionsByAddress: {}}, + }; + const store = configureTestStore(state); + + await store.dispatch(startGetRates({force: true})); + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + }); + + it('skips altCurrencyList update when context is not init and list is non-empty', async () => { + const freshRates = { + btc: [ + {code: 'USD', rate: 60000, name: 'US Dollar', fetchedOn: NOW, ts: NOW}, + ], + }; + mockedAxios.get + .mockResolvedValueOnce({data: freshRates}) + .mockResolvedValueOnce({data: freshRates}); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + APP: {altCurrencyList: [{isoCode: 'USD', name: 'US Dollar'}]}, // non-empty → skip update + WALLET: {keys: {}, customTokenOptionsByAddress: {}}, + }; + const store = configureTestStore(state); + + // No context (not 'init'), altCurrencyList has items → should NOT update + await store.dispatch(startGetRates({force: true})); + expect(mockedAxios.get).toHaveBeenCalledTimes(2); // rates still fetched + }); + + it('resolves with merged rates including token rates', async () => { + const freshRates = { + btc: [ + {code: 'USD', rate: 60000, name: 'Bitcoin', fetchedOn: NOW, ts: NOW}, + ], + }; + mockedAxios.get + .mockResolvedValueOnce({data: freshRates}) + .mockResolvedValueOnce({data: freshRates}); + + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: {}, + ratesCacheKey: {}, + }, + APP: {altCurrencyList: [{isoCode: 'USD', name: 'US Dollar'}]}, + WALLET: {keys: {}, customTokenOptionsByAddress: {}}, + }; + const store = configureTestStore(state); + + const result = await store.dispatch(startGetRates({force: true})); + expect(result).toHaveProperty('btc'); + }); +}); + +// --------------------------------------------------------------------------- +// getContractAddresses – extracts token addresses from wallets +// --------------------------------------------------------------------------- +describe('getContractAddresses', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns empty array when there are no keys', () => { + const state = { + WALLET: {keys: {}, customTokenOptionsByAddress: {}}, + }; + const store = configureTestStore(state); + const result = store.dispatch(getContractAddresses('eth')); + expect(result).toEqual([]); + }); + + it('returns token addresses for wallets on matching chain with tokens', () => { + const mockWalletId = 'wallet-eth-1'; + const tokenId = `${mockWalletId}-0xTokenAddress`; + const state = { + WALLET: { + keys: { + key1: { + wallets: [ + { + id: mockWalletId, + chain: 'eth', + currencyAbbreviation: 'eth', // not an ERC token → included + tokens: [tokenId], + }, + ], + }, + }, + customTokenOptionsByAddress: {}, + }, + }; + const store = configureTestStore(state as any); + const result = store.dispatch(getContractAddresses('eth')); + // Token address extracted (strips wallet id prefix) + expect(result).toContain('0xTokenAddress'); + }); + + it('skips wallets on non-matching chain', () => { + const state = { + WALLET: { + keys: { + key1: { + wallets: [ + { + id: 'wallet-matic-1', + chain: 'matic', + currencyAbbreviation: 'matic', + tokens: ['wallet-matic-1-0xPolygonToken'], + }, + ], + }, + }, + customTokenOptionsByAddress: {}, + }, + }; + const store = configureTestStore(state as any); + // Looking for eth chain → matic wallet should be skipped + const result = store.dispatch(getContractAddresses('eth')); + expect(result).toEqual([]); + }); + + it('deduplicates token addresses across wallets', () => { + const tokenAddress = '0xSharedToken'; + const state = { + WALLET: { + keys: { + key1: { + wallets: [ + { + id: 'w1', + chain: 'eth', + currencyAbbreviation: 'eth', + tokens: [`w1-${tokenAddress}`], + }, + { + id: 'w2', + chain: 'eth', + currencyAbbreviation: 'eth', + tokens: [`w2-${tokenAddress}`], + }, + ], + }, + }, + customTokenOptionsByAddress: {}, + }, + }; + const store = configureTestStore(state as any); + const result = store.dispatch(getContractAddresses('eth')); + // Same address from two wallets → deduped to one + const uniqueCount = new Set(result).size; + expect(uniqueCount).toBe(result.length); + }); +}); + +// --------------------------------------------------------------------------- +// refreshFiatRateSeries – additional edge cases +// --------------------------------------------------------------------------- +describe('refreshFiatRateSeries – additional edge cases', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns false when lastTs is missing from the last point', async () => { + // Artificially create a cached series with a point that has ts=0 (falsy) + const cacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: { + [cacheKey]: {fetchedOn: Date.now(), points: [{ts: 0, rate: 30000}]}, + }, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1D', + spotRate: 35000, + }), + ); + + // ts=0 is falsy → returns false + expect(result).toBe(false); + }); + + it('returns false when cadence has not elapsed for 1W interval', async () => { + // 1W fallback cadence is 2 hours (7200000 ms). + // With only 1 point and last point 30 min ago → cadence not elapsed → false + const recentTs = Date.now() - 30 * 60 * 1000; // 30 min ago + const cacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1W'); + const state = { + RATE: { + rates: {}, + lastDayRates: {}, + fiatRateSeriesCache: { + [cacheKey]: { + fetchedOn: Date.now(), + points: [{ts: recentTs, rate: 30000}], + }, + }, + ratesCacheKey: {}, + }, + }; + const store = configureTestStore(state); + + // Single point → uses fallback cadence (2h); last is 30 min → below cadence → false + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1W', + spotRate: 35000, + }), + ); + + expect(result).toBe(false); + }); + + it('refreshes for 1M interval (cadence 6h) when last point is old enough', async () => { + const oldTs1 = Date.now() - 14 * 60 * 60 * 1000; // 14h ago + const oldTs2 = Date.now() - 7 * 60 * 60 * 1000; // 7h ago + const state = buildStateWithCache('btc', '1M', [ + {ts: oldTs1, rate: 29000}, + {ts: oldTs2, rate: 30000}, + ]); + const store = configureTestStore(state); + + // 1M cadence is 6h; last point is 7h ago → above cadence → should refresh + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1M', + spotRate: 35000, + }), + ); + + expect(result).toBe(true); + }); + + it('refreshes for 3M interval (cadence 24h) when last point is 25h old', async () => { + const oldTs = Date.now() - 25 * 60 * 60 * 1000; + const state = buildStateWithCache('btc', '3M', [{ts: oldTs, rate: 30000}]); + const store = configureTestStore(state); + + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '3M', + spotRate: 35000, + }), + ); + + expect(result).toBe(true); + }); + + it('refreshes for 1Y interval (cadence 24h) when last point is 25h old', async () => { + const oldTs = Date.now() - 25 * 60 * 60 * 1000; + const state = buildStateWithCache('btc', '1Y', [{ts: oldTs, rate: 30000}]); + const store = configureTestStore(state); + + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1Y', + spotRate: 35000, + }), + ); + + expect(result).toBe(true); + }); + + it('refreshes for 5Y interval (cadence 24h) when last point is 25h old', async () => { + const oldTs = Date.now() - 25 * 60 * 60 * 1000; + const state = buildStateWithCache('btc', '5Y', [{ts: oldTs, rate: 30000}]); + const store = configureTestStore(state); + + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '5Y', + spotRate: 35000, + }), + ); + + expect(result).toBe(true); + }); + + it('uses cadence derived from actual point delta when 2+ points available', async () => { + // 2 points with 30-min gap → cadence = 30 min + // last point is 31 min ago → should refresh + const oldTs1 = Date.now() - 61 * 60 * 1000; + const oldTs2 = Date.now() - 31 * 60 * 1000; + const state = buildStateWithCache('eth', '1W', [ + {ts: oldTs1, rate: 1800}, + {ts: oldTs2, rate: 1900}, + ]); + const store = configureTestStore(state); + + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'eth', + interval: '1W', + spotRate: 2000, + }), + ); + + expect(result).toBe(true); + }); + + it('truncates sliding window – final points array is no longer than original length', async () => { + // Build state with exactly 3 points + const t1 = Date.now() - 3 * 15 * 60 * 1000; + const t2 = Date.now() - 2 * 15 * 60 * 1000; + const t3 = Date.now() - 15 * 60 * 1000; // 15 min ago → at cadence boundary + const state = buildStateWithCache('btc', '1D', [ + {ts: t1, rate: 29000}, + {ts: t2, rate: 30000}, + {ts: t3, rate: 31000}, + ]); + const store = configureTestStore(state); + + const result = await store.dispatch( + refreshFiatRateSeries({ + fiatCode: 'USD', + currencyAbbreviation: 'btc', + interval: '1D', + spotRate: 32000, + }), + ); + + const cacheKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const storedSeries = store.getState().RATE?.fiatRateSeriesCache?.[cacheKey]; + // targetLength was 3; after append+trim → still 3 + expect(result).toBe(true); + expect(storedSeries?.points?.length).toBe(3); + // New rate should be present + const rates = storedSeries?.points?.map((p: any) => p.rate) ?? []; + expect(rates).toContain(32000); + }); +}); diff --git a/src/store/wallet/effects/status/status.spec.ts b/src/store/wallet/effects/status/status.spec.ts new file mode 100644 index 0000000000..1da913f933 --- /dev/null +++ b/src/store/wallet/effects/status/status.spec.ts @@ -0,0 +1,943 @@ +/** + * Tests for src/store/wallet/effects/status/status.ts + * + * Strategy: + * - Pure / near-pure effects (buildBalance, buildFiatBalance, buildPendingTxps, + * GetWalletBalance, getTokenContractInfo, getBulkStatus) are tested with + * light mocks. + * - Thunk effects that reach out to network (updateKeyStatus, + * startUpdateAllWalletStatusForKeys, startUpdateAllKeyAndWalletStatus, etc.) + * are tested by mocking the heavy deps and verifying that the correct Redux + * actions get dispatched. + */ + +import configureTestStore from '@test/store'; +import {Network} from '../../../../constants'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +// BwcProvider — isolate network calls and prevent native module crashes +// (tss-send calls BwcProvider.getInstance().getTssSign() at module load time) +jest.mock('../../../../lib/bwc', () => { + const mockBulkClient = { + getStatusAll: jest.fn((_creds: any, _opts: any, cb: any) => cb(null, [])), + }; + const mockClient = { + bulkClient: mockBulkClient, + getStatusAll: mockBulkClient.getStatusAll, + }; + const mockInstance = { + getClient: jest.fn(() => mockClient), + getTssSign: jest.fn(() => class MockTssSign {}), + getTssKey: jest.fn(() => class MockTssKey {}), + createTssKey: jest.fn(), + createKeyGen: jest.fn(), + getErrors: jest.fn(() => ({})), + getUtils: jest.fn(() => ({})), + getBitcore: jest.fn(() => ({})), + getCore: jest.fn(() => ({})), + getConstants: jest.fn(() => ({})), + getLogger: jest.fn(() => ({})), + getPayProV2: jest.fn(() => ({trustedKeys: {}})), + getEncryption: jest.fn(() => ({})), + createKey: jest.fn(), + parseSecret: jest.fn(), + }; + return { + BwcProvider: { + getInstance: jest.fn(() => mockInstance), + API: {}, + instance: mockInstance, + }, + }; +}); + +// FormatAmount — return a deterministic string so math doesn't depend on BWC +jest.mock('../amount/amount', () => ({ + FormatAmount: jest.fn( + (currency: string, _chain: string, _addr: any, sats: number) => () => + `${sats} ${currency}`, + ), + FormatAmountStr: jest.fn( + (currency: string, _chain: string, _addr: any, sats: number) => () => + `${sats} ${currency}`, + ), +})); + +// toFiat — simple pass-through so sats === fiat for testing +jest.mock('../../utils/wallet', () => ({ + ...jest.requireActual('../../utils/wallet'), + isCacheKeyStale: jest.fn(() => true), // stale by default → updates proceed + findWalletById: jest.fn(), + toFiat: (sats: number) => () => sats / 1e8, // 1 sat = 1e-8 "fiat" +})); + +// convertToFiat — just return the first argument +jest.mock('../../../../utils/helper-methods', () => ({ + ...jest.requireActual('../../../../utils/helper-methods'), + convertToFiat: jest.fn((fiatAmount: number) => fiatAmount ?? 0), + checkEncryptedKeysForEddsaMigration: jest.fn(() => () => Promise.resolve()), + isL2NoSideChainNetwork: jest.fn((chain: string) => + ['arb', 'base', 'op'].includes((chain || '').toLowerCase()), + ), +})); + +// ProcessPendingTxps — return a fixed list +jest.mock('../transactions/transactions', () => ({ + ProcessPendingTxps: jest.fn(() => () => [{id: 'txp-1'}]), + RemoveTxProposal: jest.fn(() => Promise.resolve()), +})); + +// detectAndCreateTokensForEachEvmWallet — no-op +jest.mock('../create/create', () => ({ + ...jest.requireActual('../create/create'), + detectAndCreateTokensForEachEvmWallet: jest.fn(() => () => Promise.resolve()), +})); + +// createWalletAddress — no-op +jest.mock('../address/address', () => ({ + createWalletAddress: jest.fn(() => () => Promise.resolve('mock-address')), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +import {BwcProvider} from '../../../../lib/bwc'; +import {isCacheKeyStale} from '../../utils/wallet'; +import {convertToFiat} from '../../../../utils/helper-methods'; + +/** Minimal WalletBalance used as cached balance in tests */ +const makeCachedBalance = (sat = 1_000_000) => ({ + sat, + satAvailable: sat, + satLocked: 0, + satConfirmedLocked: 0, + satConfirmed: sat, + satConfirmedAvailable: sat, + satSpendable: sat, + satPending: 0, + crypto: `${sat} btc`, + cryptoLocked: '0 btc', + cryptoConfirmedLocked: '0 btc', + cryptoSpendable: `${sat} btc`, + cryptoPending: '0 btc', + fiat: sat / 1e8, + fiatLastDay: sat / 1e8, + fiatLocked: 0, + fiatConfirmedLocked: 0, + fiatSpendable: sat / 1e8, + fiatPending: 0, +}); + +/** Minimal Wallet object */ +const makeWallet = (overrides: Partial = {}): any => ({ + id: 'wallet-1', + keyId: 'key-1', + currencyAbbreviation: 'btc', + chain: 'btc', + network: Network.mainnet, + balance: makeCachedBalance(), + pendingTxps: [], + singleAddress: false, + receiveAddress: 'mock-receive-address', + hideWallet: false, + hideWalletByAccount: false, + credentials: { + copayerId: 'copayer-1', + token: null, + multisigEthInfo: null, + isComplete: () => true, + }, + tokens: undefined, + tokenAddress: undefined, + getStatus: jest.fn(), + getBalance: jest.fn(), + getTokenContractInfo: jest.fn(), + tssKeyId: undefined, + pendingTssSession: false, + ...overrides, +}); + +/** Minimal Key object */ +const makeKey = (wallets: any[] = [], overrides: Partial = {}): any => ({ + id: 'key-1', + wallets, + properties: undefined, + methods: undefined, + totalBalance: 0, + totalBalanceLastDay: 0, + hideKeyBalance: false, + isReadOnly: false, + ...overrides, +}); + +/** Minimal Status returned from BWC */ +const makeStatus = (overrides: Partial = {}): any => ({ + balance: { + totalAmount: 2_000_000, + totalConfirmedAmount: 2_000_000, + lockedAmount: 0, + lockedConfirmedAmount: 0, + availableAmount: 2_000_000, + availableConfirmedAmount: 2_000_000, + }, + pendingTxps: [], + wallet: {singleAddress: false}, + ...overrides, +}); + +/** Minimal BulkStatus entry */ +const makeBulkStatus = ( + walletId = 'wallet-1', + overrides: Partial = {}, +): any => ({ + walletId, + success: true, + status: makeStatus(), + ...overrides, +}); + +// ─── Import after mocks ─────────────────────────────────────────────────────── + +import { + buildBalance, + buildFiatBalance, + buildPendingTxps, + getBulkStatus, + GetWalletBalance, + getTokenContractInfo, + startUpdateWalletStatus, + startUpdateAllWalletStatusForKeys, + startUpdateAllWalletStatusForReadOnlyKeys, + startUpdateAllWalletStatusForKey, + startUpdateAllKeyAndWalletStatus, + updateWalletStatus, + FormatKeyBalances, + startFormatBalanceAllWalletsForKey, +} from './status'; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('getBulkStatus', () => { + it('resolves with data when bulkClient.getStatusAll succeeds', async () => { + const mockData = [{walletId: 'w1', success: true, status: {}}]; + const bulkClient = { + getStatusAll: jest.fn((_creds, _opts, cb) => cb(null, mockData)), + }; + const result = await getBulkStatus(bulkClient, [], {}); + expect(result).toEqual(mockData); + }); + + it('rejects when bulkClient.getStatusAll errors', async () => { + const error = new Error('network error'); + const bulkClient = { + getStatusAll: jest.fn((_creds, _opts, cb) => cb(error, null)), + }; + await expect(getBulkStatus(bulkClient, [], {})).rejects.toEqual(error); + }); + + it('calls getStatusAll with includeExtendedInfo and twoStep options', async () => { + const bulkClient = { + getStatusAll: jest.fn((_creds, opts, cb) => { + expect(opts.includeExtendedInfo).toBe(true); + expect(opts.twoStep).toBe(true); + cb(null, []); + }), + }; + await getBulkStatus(bulkClient, ['cred1'], { + w1: {tokenAddresses: undefined}, + }); + expect(bulkClient.getStatusAll).toHaveBeenCalledTimes(1); + }); +}); + +describe('GetWalletBalance', () => { + it('resolves with the balance returned from wallet.getBalance', async () => { + const mockBalance = {totalAmount: 5000}; + const wallet = makeWallet(); + wallet.getBalance = jest.fn((_opts, cb) => cb(null, mockBalance)); + + const result = await GetWalletBalance(wallet, {}); + expect(result).toEqual(mockBalance); + }); + + it('rejects when wallet.getBalance returns an error', async () => { + const error = new Error('balance error'); + const wallet = makeWallet(); + wallet.getBalance = jest.fn((_opts, cb) => cb(error, null)); + + await expect(GetWalletBalance(wallet, {})).rejects.toEqual(error); + }); + + it('passes opts through to getBalance', async () => { + const wallet = makeWallet(); + wallet.getBalance = jest.fn((_opts, cb) => cb(null, {})); + const opts = {includeExtendedInfo: true}; + + await GetWalletBalance(wallet, opts); + expect(wallet.getBalance).toHaveBeenCalledWith(opts, expect.any(Function)); + }); +}); + +describe('getTokenContractInfo', () => { + it('resolves with the token info when successful', async () => { + const mockInfo = {symbol: 'USDC', decimals: 6}; + const wallet = makeWallet(); + wallet.getTokenContractInfo = jest.fn((_opts, cb) => cb(null, mockInfo)); + + const result = await getTokenContractInfo(wallet, {}); + expect(result).toEqual(mockInfo); + }); + + it('rejects when getTokenContractInfo errors', async () => { + const error = new Error('contract error'); + const wallet = makeWallet(); + wallet.getTokenContractInfo = jest.fn((_opts, cb) => cb(error, null)); + + await expect(getTokenContractInfo(wallet, {})).rejects.toEqual(error); + }); +}); + +describe('buildBalance', () => { + it('returns a CryptoBalance object with correct sat values for btc', () => { + const store = configureTestStore({WALLET: {useUnconfirmedFunds: false}}); + const wallet = makeWallet(); + const status = makeStatus({ + balance: { + totalAmount: 3_000_000, + totalConfirmedAmount: 3_000_000, + lockedAmount: 500_000, + lockedConfirmedAmount: 500_000, + availableAmount: 2_500_000, + availableConfirmedAmount: 2_500_000, + }, + }); + + const result = store.dispatch(buildBalance({wallet, status})); + + expect(result.sat).toBe(3_000_000); + expect(result.satLocked).toBe(500_000); + expect(result.satAvailable).toBe(2_500_000); + expect(result.satSpendable).toBe(2_500_000); // confirmed - locked, no unconfirmed funds + expect(result.satPending).toBe(0); + }); + + it('adjusts sat values for xrp chain', () => { + const store = configureTestStore({WALLET: {useUnconfirmedFunds: false}}); + const wallet = makeWallet({chain: 'xrp', currencyAbbreviation: 'xrp'}); + const status = makeStatus({ + balance: { + totalAmount: 10_000_000, + totalConfirmedAmount: 10_000_000, + lockedAmount: 2_000_000, + lockedConfirmedAmount: 1_000_000, + availableAmount: 8_000_000, + availableConfirmedAmount: 8_000_000, + }, + }); + + const result = store.dispatch(buildBalance({wallet, status})); + + // satLockedAmount = lockedAmount - lockedConfirmedAmount + expect(result.satLocked).toBe(1_000_000); + // satTotalAmount = totalAmount - lockedConfirmedAmount + expect(result.sat).toBe(9_000_000); + }); + + it('adjusts sat values for sol chain', () => { + const store = configureTestStore({WALLET: {useUnconfirmedFunds: false}}); + const wallet = makeWallet({chain: 'sol', currencyAbbreviation: 'sol'}); + const status = makeStatus({ + balance: { + totalAmount: 5_000_000, + totalConfirmedAmount: 5_000_000, + lockedAmount: 1_000_000, + lockedConfirmedAmount: 500_000, + availableAmount: 4_000_000, + availableConfirmedAmount: 4_000_000, + }, + }); + + const result = store.dispatch(buildBalance({wallet, status})); + expect(result.satLocked).toBe(500_000); + expect(result.sat).toBe(4_500_000); + }); + + it('uses unconfirmed funds for spendable amount when useUnconfirmedFunds is true', () => { + const store = configureTestStore({WALLET: {useUnconfirmedFunds: true}}); + const wallet = makeWallet(); + const status = makeStatus({ + balance: { + totalAmount: 3_000_000, + totalConfirmedAmount: 2_000_000, + lockedAmount: 500_000, + lockedConfirmedAmount: 500_000, + availableAmount: 2_500_000, + availableConfirmedAmount: 1_500_000, + }, + }); + + const result = store.dispatch(buildBalance({wallet, status})); + // spendable = totalAmount - lockedAmount when useUnconfirmedFunds + expect(result.satSpendable).toBe(2_500_000); + }); + + it('returns crypto formatted string fields', () => { + const store = configureTestStore({WALLET: {useUnconfirmedFunds: false}}); + const wallet = makeWallet({currencyAbbreviation: 'btc'}); + const status = makeStatus(); + + const result = store.dispatch(buildBalance({wallet, status})); + + expect(typeof result.crypto).toBe('string'); + expect(typeof result.cryptoLocked).toBe('string'); + expect(typeof result.cryptoSpendable).toBe('string'); + }); +}); + +describe('buildFiatBalance', () => { + it('returns a FiatBalance object with numeric fields', () => { + const store = configureTestStore({}); + const wallet = makeWallet(); + const cryptoBalance = makeCachedBalance(1_000_000); + const rates = {}; + const lastDayRates = {}; + + const result = store.dispatch( + buildFiatBalance({ + wallet, + cryptoBalance, + defaultAltCurrencyIsoCode: 'USD', + rates, + lastDayRates, + }), + ); + + expect(typeof result.fiat).toBe('number'); + expect(typeof result.fiatLastDay).toBe('number'); + expect(typeof result.fiatLocked).toBe('number'); + expect(typeof result.fiatSpendable).toBe('number'); + expect(typeof result.fiatPending).toBe('number'); + }); + + it('returns fiatLastDay based on lastDayRates (different from fiat when rates differ)', () => { + // toFiat mock returns sats/1e8; since rates and lastDayRates differ only semantically here, + // we just verify fiatLastDay is a number and present in the result. + const store = configureTestStore({}); + const wallet = makeWallet(); + const cryptoBalance = makeCachedBalance(1_000_000); + + const result = store.dispatch( + buildFiatBalance({ + wallet, + cryptoBalance, + defaultAltCurrencyIsoCode: 'USD', + rates: {BTC: {USD: 50000}}, + lastDayRates: {BTC: {USD: 49000}}, + }), + ); + + expect(typeof result.fiatLastDay).toBe('number'); + expect(Object.keys(result)).toContain('fiatLastDay'); + }); +}); + +describe('buildPendingTxps', () => { + it('returns empty array for ERC token wallets', () => { + const store = configureTestStore({}); + // shib is an ERC token on eth chain + const wallet = makeWallet({currencyAbbreviation: 'shib', chain: 'eth'}); + const status = makeStatus({pendingTxps: [{id: 'txp-1'}]}); + + const result = store.dispatch(buildPendingTxps({wallet, status})); + expect(result).toEqual([]); + }); + + it('returns pending txps for non-ERC wallets', () => { + const store = configureTestStore({}); + const wallet = makeWallet({currencyAbbreviation: 'btc', chain: 'btc'}); + const status = makeStatus({pendingTxps: [{id: 'txp-1'}]}); + + const result = store.dispatch(buildPendingTxps({wallet, status})); + expect(result).toEqual([{id: 'txp-1'}]); + }); + + it('returns empty array when there are no pending txps', () => { + const store = configureTestStore({}); + const wallet = makeWallet(); + const status = makeStatus({pendingTxps: []}); + + const result = store.dispatch(buildPendingTxps({wallet, status})); + expect(result).toEqual([]); + }); + + it('returns all TSS txps regardless of age', () => { + const store = configureTestStore({}); + const now = Math.floor(Date.now() / 1000); + const wallet = makeWallet({tssKeyId: 'tss-key-1'}); + + const expiredTxp = {id: 'expired', createdOn: now - 11 * 60}; + const freshTxp = {id: 'fresh', createdOn: now - 60}; + + const {ProcessPendingTxps} = require('../transactions/transactions'); + (ProcessPendingTxps as jest.Mock).mockImplementationOnce(() => () => [ + expiredTxp, + freshTxp, + ]); + + const status = makeStatus({pendingTxps: [expiredTxp, freshTxp]}); + const result = store.dispatch(buildPendingTxps({wallet, status})); + + expect(result).toEqual([expiredTxp, freshTxp]); + expect(result.some(t => t.id === 'expired')).toBe(true); + }); +}); + +describe('startUpdateWalletStatus', () => { + it('calls wallet.getStatus when cache is stale (force=true)', async () => { + // isCacheKeyStale returns true by default (set at top-level mock factory) + const store = configureTestStore({ + WALLET: { + balanceCacheKey: {}, + useUnconfirmedFunds: false, + keys: {'key-1': {wallets: [makeWallet()]}}, + }, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + const wallet = makeWallet(); + wallet.getStatus = jest.fn((_opts, cb) => cb(null, makeStatus())); + + const key = makeKey([wallet]); + + await store.dispatch(startUpdateWalletStatus({key, wallet, force: true})); + expect(wallet.getStatus).toHaveBeenCalledTimes(1); + }); + + it('rejects when key is missing', async () => { + const store = configureTestStore({ + WALLET: {balanceCacheKey: {}, useUnconfirmedFunds: false}, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + const wallet = makeWallet(); + await expect( + store.dispatch(startUpdateWalletStatus({key: null as any, wallet})), + ).rejects.toBeUndefined(); + }); + + it('rejects when wallet is missing', async () => { + const store = configureTestStore({ + WALLET: {balanceCacheKey: {}, useUnconfirmedFunds: false}, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + const key = makeKey([]); + await expect( + store.dispatch(startUpdateWalletStatus({key, wallet: null as any})), + ).rejects.toBeUndefined(); + }); + + it('dispatches successUpdateWalletStatus after a successful update', async () => { + const store = configureTestStore({ + WALLET: { + balanceCacheKey: {}, + useUnconfirmedFunds: false, + keys: { + 'key-1': { + wallets: [makeWallet()], + }, + }, + }, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + const wallet = makeWallet(); + // wallet.getStatus resolves successfully + wallet.getStatus = jest.fn((_opts, cb) => cb(null, makeStatus())); + + const key = makeKey([wallet]); + + await store.dispatch(startUpdateWalletStatus({key, wallet, force: true})); + + // The store should have updated the wallet status actions — check state + const state = store.getState(); + // If successUpdateWalletStatus dispatched, the wallet balance gets stored + // (wallet reducer handles SUCCESS_UPDATE_WALLET_STATUS) + expect(state.WALLET).toBeDefined(); + }); +}); + +describe('startUpdateAllWalletStatusForReadOnlyKeys', () => { + it('resolves successfully with no wallets', async () => { + const store = configureTestStore({ + WALLET: {balanceCacheKey: {}, useUnconfirmedFunds: false}, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + const readOnlyKey = makeKey([], {isReadOnly: true}); + await expect( + store.dispatch( + startUpdateAllWalletStatusForReadOnlyKeys({ + readOnlyKeys: [readOnlyKey], + force: true, + }), + ), + ).resolves.toBeUndefined(); + }); +}); + +describe('startUpdateAllWalletStatusForKey', () => { + it('routes to startUpdateAllWalletStatusForKeys when key is not read-only', async () => { + (isCacheKeyStale as jest.Mock).mockReturnValue(true); + + // Mock BwcProvider to have a working getStatusAll + const mockGetStatusAll = jest.fn((_creds, _opts, cb) => cb(null, [])); + (BwcProvider.getInstance as jest.Mock).mockReturnValue({ + getClient: jest.fn(() => ({ + bulkClient: {getStatusAll: mockGetStatusAll}, + })), + }); + + const store = configureTestStore({ + WALLET: { + balanceCacheKey: {}, + useUnconfirmedFunds: false, + keys: {'key-1': makeKey([makeWallet()])}, + }, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + const wallet = makeWallet(); + wallet.getStatus = jest.fn((_opts, cb) => cb(null, makeStatus())); + const key = makeKey([wallet], {isReadOnly: false}); + + // Should not throw + await expect( + store.dispatch(startUpdateAllWalletStatusForKey({key, force: true})), + ).resolves.toBeUndefined(); + }); + + it('routes to startUpdateAllWalletStatusForReadOnlyKeys when key is read-only', async () => { + const store = configureTestStore({ + WALLET: {balanceCacheKey: {}, useUnconfirmedFunds: false, keys: {}}, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + const wallet = makeWallet(); + wallet.getStatus = jest.fn((_opts, cb) => cb(null, makeStatus())); + const readOnlyKey = makeKey([wallet], {isReadOnly: true}); + + await expect( + store.dispatch( + startUpdateAllWalletStatusForKey({key: readOnlyKey, force: true}), + ), + ).resolves.toBeUndefined(); + }); +}); + +describe('startUpdateAllWalletStatusForKeys', () => { + it('resolves when called with no keys', async () => { + const store = configureTestStore({ + WALLET: {balanceCacheKey: {}, useUnconfirmedFunds: false, keys: {}}, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + await expect( + store.dispatch( + startUpdateAllWalletStatusForKeys({keys: [], force: true}), + ), + ).resolves.toBeUndefined(); + }); + + it('dispatches failedUpdateKeyTotalBalance when getBulkStatus fails', async () => { + (isCacheKeyStale as jest.Mock).mockReturnValue(true); + + const error = new Error('network down'); + const mockGetStatusAll = jest.fn((_creds, _opts, cb) => cb(error, null)); + (BwcProvider.getInstance as jest.Mock).mockReturnValue({ + getClient: jest.fn(() => ({ + bulkClient: {getStatusAll: mockGetStatusAll}, + })), + }); + + const wallet = makeWallet({ + credentials: { + copayerId: 'copayer-1', + token: null, + multisigEthInfo: null, + isComplete: () => true, + }, + }); + const key = makeKey([wallet]); + + const store = configureTestStore({ + WALLET: { + balanceCacheKey: {}, + useUnconfirmedFunds: false, + keys: {'key-1': key}, + }, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + await expect( + store.dispatch( + startUpdateAllWalletStatusForKeys({keys: [key], force: true}), + ), + ).rejects.toThrow(); + }); +}); + +describe('startUpdateAllKeyAndWalletStatus', () => { + it('resolves early when all cache keys are fresh and force is false', async () => { + (isCacheKeyStale as jest.Mock).mockReturnValue(false); + + const store = configureTestStore({ + WALLET: { + balanceCacheKey: {all: Date.now()}, + useUnconfirmedFunds: false, + keys: {}, + }, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + await expect( + store.dispatch(startUpdateAllKeyAndWalletStatus({})), + ).resolves.toBeUndefined(); + }); + + it('dispatches failedUpdateAllKeysAndStatus when an update fails', async () => { + (isCacheKeyStale as jest.Mock).mockReturnValue(true); + + const error = new Error('bulk fail'); + const mockGetStatusAll = jest.fn((_creds, _opts, cb) => cb(error, null)); + (BwcProvider.getInstance as jest.Mock).mockReturnValue({ + getClient: jest.fn(() => ({ + bulkClient: {getStatusAll: mockGetStatusAll}, + })), + }); + + const wallet = makeWallet({ + credentials: { + copayerId: 'copayer-1', + token: null, + multisigEthInfo: null, + isComplete: () => true, + }, + }); + const key = makeKey([wallet]); + + const store = configureTestStore({ + WALLET: { + balanceCacheKey: {}, + useUnconfirmedFunds: false, + keys: {'key-1': key}, + }, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + await expect( + store.dispatch(startUpdateAllKeyAndWalletStatus({force: true})), + ).rejects.toThrow(); + }); + + it('resolves when there are no keys', async () => { + (isCacheKeyStale as jest.Mock).mockReturnValue(true); + + const store = configureTestStore({ + WALLET: { + balanceCacheKey: {}, + useUnconfirmedFunds: false, + keys: {}, + }, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + await expect( + store.dispatch( + startUpdateAllKeyAndWalletStatus({force: true, context: 'init'}), + ), + ).resolves.toBeUndefined(); + }); +}); + +describe('updateWalletStatus', () => { + it('resolves with cached balance when getStatus returns an error', async () => { + const store = configureTestStore({WALLET: {useUnconfirmedFunds: false}}); + + const wallet = makeWallet(); + wallet.getStatus = jest.fn((_opts, cb) => + cb(new Error('getStatus error'), null), + ); + + const result = await store.dispatch( + updateWalletStatus({ + wallet, + defaultAltCurrencyIsoCode: 'USD', + rates: {}, + lastDayRates: {}, + }), + ); + + // Should fall back to cached balance + expect(result.balance).toBeDefined(); + expect(result.pendingTxps).toBe(wallet.pendingTxps); + }); + + it('resolves with updated balance on successful getStatus', async () => { + const store = configureTestStore({WALLET: {useUnconfirmedFunds: false}}); + + const wallet = makeWallet(); + wallet.getStatus = jest.fn((_opts, cb) => cb(null, makeStatus())); + + const result = await store.dispatch( + updateWalletStatus({ + wallet, + defaultAltCurrencyIsoCode: 'USD', + rates: {}, + lastDayRates: {}, + }), + ); + + expect(result.balance).toBeDefined(); + expect(result.balance.sat).toBeGreaterThanOrEqual(0); + expect(Array.isArray(result.pendingTxps)).toBe(true); + }); + + it('creates a new address when receiveAddress is missing', async () => { + const {createWalletAddress} = require('../address/address'); + const store = configureTestStore({WALLET: {useUnconfirmedFunds: false}}); + + const wallet = makeWallet({receiveAddress: undefined}); + wallet.getStatus = jest.fn((_opts, cb) => cb(null, makeStatus())); + + await store.dispatch( + updateWalletStatus({ + wallet, + defaultAltCurrencyIsoCode: 'USD', + rates: {}, + lastDayRates: {}, + }), + ); + + expect(createWalletAddress).toHaveBeenCalled(); + }); +}); + +describe('FormatKeyBalances', () => { + it('resolves when there are no keys in state', async () => { + const store = configureTestStore({ + WALLET: {keys: {}, useUnconfirmedFunds: false}, + RATE: {rates: {}, lastDayRates: {}}, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + }); + + await expect(store.dispatch(FormatKeyBalances())).resolves.toBeUndefined(); + }); +}); + +describe('startFormatBalanceAllWalletsForKey', () => { + it('resolves and calls successUpdateKey', async () => { + const wallet = makeWallet(); + const key = makeKey([wallet]); + + const store = configureTestStore({ + WALLET: {useUnconfirmedFunds: false, keys: {'key-1': key}}, + RATE: {rates: {}, lastDayRates: {}}, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + }); + + await expect( + store.dispatch( + startFormatBalanceAllWalletsForKey({ + key, + defaultAltCurrencyIsoCode: 'USD', + rates: {}, + lastDayRates: {}, + }), + ), + ).resolves.toBeUndefined(); + }); + + it('handles wallet with zero balance without throwing', async () => { + const wallet = makeWallet({balance: makeCachedBalance(0)}); + const key = makeKey([wallet]); + + const store = configureTestStore({ + WALLET: {useUnconfirmedFunds: false, keys: {'key-1': key}}, + RATE: {rates: {}, lastDayRates: {}}, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + }); + + await expect( + store.dispatch( + startFormatBalanceAllWalletsForKey({ + key, + defaultAltCurrencyIsoCode: 'USD', + rates: {}, + lastDayRates: {}, + }), + ), + ).resolves.toBeUndefined(); + }); +}); + +// ─── normalizeTokenAddresses (indirectly via updateKeyStatus) ───────────────── +// The private helper normalizeTokenAddresses is exercised through updateKeyStatus. +// We verify that SOL-chain token addresses are normalised to the mint address +// portion (after the last '-'). + +describe('updateKeyStatus — SOL token address normalisation', () => { + it('does not throw when a SOL wallet has token addresses', async () => { + (isCacheKeyStale as jest.Mock).mockReturnValue(true); + + const mockGetStatusAll = jest.fn((_creds, _opts, cb) => cb(null, [])); + (BwcProvider.getInstance as jest.Mock).mockReturnValue({ + getClient: jest.fn(() => ({ + bulkClient: {getStatusAll: mockGetStatusAll}, + })), + }); + + const solWallet = makeWallet({ + id: 'sol-wallet-1', + chain: 'sol', + currencyAbbreviation: 'sol', + tokens: ['sol-EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'], + credentials: { + copayerId: 'copayer-sol', + token: null, + multisigEthInfo: null, + isComplete: () => true, + }, + }); + + const key = makeKey([solWallet]); + + const store = configureTestStore({ + WALLET: { + balanceCacheKey: {}, + useUnconfirmedFunds: false, + keys: {'key-1': key}, + }, + APP: {defaultAltCurrency: {isoCode: 'USD'}}, + RATE: {rates: {}, lastDayRates: {}}, + }); + + // Should resolve (may return undefined if credentials empty after filter) + const {updateKeyStatus} = require('./status'); + const result = await store.dispatch(updateKeyStatus({key, force: true})); + // Either undefined (no credentials) or a result object — no throw + expect(result === undefined || typeof result === 'object').toBe(true); + }); +}); diff --git a/src/store/wallet/utils/wallet.spec.ts b/src/store/wallet/utils/wallet.spec.ts new file mode 100644 index 0000000000..90599aefe1 --- /dev/null +++ b/src/store/wallet/utils/wallet.spec.ts @@ -0,0 +1,1764 @@ +/** + * Tests for src/store/wallet/utils/wallet.ts + * + * Strategy: + * - Mock BwcProvider and all native/heavy deps at the top. + * - Test exported pure/utility functions with both truthy and falsy branches. + * - Avoid Redux dispatch where possible; where needed use simple function mocks. + */ + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('../../../lib/bwc', () => { + const mockInstance = { + getClient: jest.fn(() => ({})), + createKey: jest.fn(() => ({ + id: 'mock-key-id', + isPrivKeyEncrypted: jest.fn(() => false), + toObj: jest.fn(() => ({})), + })), + createTssKey: jest.fn(() => ({ + id: 'mock-tss-id', + isPrivKeyEncrypted: jest.fn(() => false), + toObj: jest.fn(() => ({})), + })), + getErrors: jest.fn(() => ({})), + getUtils: jest.fn(() => ({ + formatAmount: jest.fn((amount: number, _: string) => `${amount}`), + })), + getBitcore: jest.fn(() => ({})), + getBitcoreCash: jest.fn(() => ({})), + getBitcoreDoge: jest.fn(() => ({})), + getBitcoreLtc: jest.fn(() => ({})), + getCore: jest.fn(() => ({})), + getPayProV2: jest.fn(() => ({trustedKeys: {}})), + getTssSign: jest.fn(() => class MockTssSign {}), + getTssKey: jest.fn(() => class MockTssKey {}), + getConstants: jest.fn(() => ({ + SCRIPT_TYPES: {}, + DERIVATION_STRATEGIES: {}, + })), + getEncryption: jest.fn(() => ({})), + getLogger: jest.fn(() => ({})), + parseSecret: jest.fn(), + }; + return { + BwcProvider: { + getInstance: jest.fn(() => mockInstance), + API: {}, + instance: mockInstance, + }, + }; +}); + +jest.mock('../../../managers/LogManager', () => ({ + logManager: {info: jest.fn(), error: jest.fn(), debug: jest.fn()}, +})); + +// Mock navigation component that pulls in styled-components/SafeAreaView at module load +jest.mock('../../../navigation/tabs/home/components/Wallet', () => ({ + WALLET_DISPLAY_LIMIT: 5, +})); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import {Network} from '../../../constants'; +import { + findWalletById, + findWalletByAddress, + isCacheKeyStale, + isSegwit, + isTaproot, + getRemainingWalletCount, + GetEstimatedTxSize, + isMatch, + getMatchedKey, + getReadOnlyKey, + isMatchedWallet, + getEVMAccountName, + generateKeyExportCode, + checkEncryptPassword, + checkPrivateKeyEncrypted, + buildKeyObj, + buildTssKeyObj, + buildMigrationKeyObj, + formatCryptoAmount, + coinbaseAccountToWalletRow, + BuildCoinbaseWalletsList, + mapAbbreviationAndName, + GetProtocolPrefixAddress, + toFiat, + findMatchedKeyAndUpdate, + findKeyByKeyId, + getAllWalletClients, + findWalletByIdHashed, + buildUIFormattedWallet, + buildAccountList, + buildAssetsByChain, + buildAssetsByChainList, +} from './wallet'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const makeCredentials = (overrides: any = {}) => ({ + walletId: 'wallet-1', + keyId: 'key-1', + n: 1, + m: 1, + account: 0, + walletName: 'My Wallet', + isComplete: jest.fn(() => true), + ...overrides, +}); + +const makeBalance = (overrides: any = {}) => ({ + crypto: '0.00', + cryptoLocked: '0.00', + cryptoConfirmedLocked: '0.00', + cryptoSpendable: '0.00', + cryptoPending: '0.00', + fiat: 0, + fiatLastDay: 0, + fiatLocked: 0, + fiatConfirmedLocked: 0, + fiatSpendable: 0, + fiatPending: 0, + sat: 0, + satAvailable: 0, + satLocked: 0, + satConfirmedLocked: 0, + satConfirmed: 0, + satConfirmedAvailable: 0, + satSpendable: 0, + satPending: 0, + ...overrides, +}); + +const makeWallet = (overrides: any = {}): any => ({ + id: 'wallet-1', + credentials: makeCredentials(), + balance: makeBalance(), + chain: 'btc', + network: Network.mainnet, + keyId: 'key-1', + pendingTxps: [], + currencyAbbreviation: 'btc', + currencyName: 'Bitcoin', + img: '', + badgeImg: undefined, + chainName: 'Bitcoin', + receiveAddress: '1ABCxyz', + hideWallet: false, + hideWalletByAccount: false, + hideBalance: false, + isScanning: false, + tokenAddress: undefined, + tssMetadata: undefined, + ...overrides, +}); + +const makeKey = (overrides: any = {}): any => ({ + id: 'key-1', + wallets: [], + properties: {fingerPrint: 'fp-1'}, + methods: { + isPrivKeyEncrypted: jest.fn(() => false), + checkPassword: jest.fn(() => true), + toObj: jest.fn(() => ({mnemonicHasPassphrase: false})), + id: 'key-1', + }, + totalBalance: 0, + totalBalanceLastDay: 0, + backupComplete: false, + keyName: 'My Key', + hideKeyBalance: false, + isReadOnly: false, + ...overrides, +}); + +// ─── findWalletById ──────────────────────────────────────────────────────────── + +describe('findWalletById', () => { + it('returns matching wallet by id', () => { + const wallets = [makeWallet({id: 'w1'}), makeWallet({id: 'w2'})]; + expect(findWalletById(wallets, 'w1')).toBe(wallets[0]); + }); + + it('returns undefined when id does not match', () => { + const wallets = [makeWallet({id: 'w1'})]; + expect(findWalletById(wallets, 'w9')).toBeUndefined(); + }); + + it('returns matching wallet when copayerId matches credentials.copayerId', () => { + const wallet = makeWallet({ + id: 'w1', + credentials: makeCredentials({copayerId: 'cp1'}), + }); + expect(findWalletById([wallet], 'w1', 'cp1')).toBe(wallet); + }); + + it('returns undefined when copayerId does not match', () => { + const wallet = makeWallet({ + id: 'w1', + credentials: makeCredentials({copayerId: 'cp1'}), + }); + expect(findWalletById([wallet], 'w1', 'cp-other')).toBeUndefined(); + }); +}); + +// ─── findWalletByAddress ────────────────────────────────────────────────────── + +describe('findWalletByAddress', () => { + it('returns wallet matching address, chain and network', () => { + const wallet = makeWallet({ + receiveAddress: '0xABC', + chain: 'eth', + network: Network.mainnet, + }); + const keys: any = {'key-1': {wallets: [wallet]}}; + const result = findWalletByAddress('0xABC', 'eth', Network.mainnet, keys); + expect(result).toBe(wallet); + }); + + it('returns undefined when address does not match', () => { + const wallet = makeWallet({ + receiveAddress: '0xDEF', + chain: 'eth', + network: Network.mainnet, + }); + const keys: any = {'key-1': {wallets: [wallet]}}; + expect( + findWalletByAddress('0xABC', 'eth', Network.mainnet, keys), + ).toBeUndefined(); + }); + + it('returns undefined when chain does not match', () => { + const wallet = makeWallet({ + receiveAddress: '0xABC', + chain: 'btc', + network: Network.mainnet, + }); + const keys: any = {'key-1': {wallets: [wallet]}}; + expect( + findWalletByAddress('0xABC', 'eth', Network.mainnet, keys), + ).toBeUndefined(); + }); + + it('performs case-insensitive address comparison', () => { + const wallet = makeWallet({ + receiveAddress: '0xabc', + chain: 'eth', + network: Network.mainnet, + }); + const keys: any = {'key-1': {wallets: [wallet]}}; + expect(findWalletByAddress('0xABC', 'eth', Network.mainnet, keys)).toBe( + wallet, + ); + }); + + it('returns undefined when keys object is empty', () => { + expect( + findWalletByAddress('0xABC', 'eth', Network.mainnet, {}), + ).toBeUndefined(); + }); +}); + +// ─── isCacheKeyStale ────────────────────────────────────────────────────────── + +describe('isCacheKeyStale', () => { + it('returns true when timestamp is undefined', () => { + expect(isCacheKeyStale(undefined, 60)).toBe(true); + }); + + it('returns true when timestamp is 0 (falsy)', () => { + expect(isCacheKeyStale(0, 60)).toBe(true); + }); + + it('returns true when TTL has elapsed', () => { + const oldTimestamp = Date.now() - 2 * 60 * 1000; // 2 minutes ago + expect(isCacheKeyStale(oldTimestamp, 1)).toBe(true); // TTL = 1s + }); + + it('returns false when TTL has not elapsed', () => { + const recentTimestamp = Date.now() - 100; // 100ms ago + expect(isCacheKeyStale(recentTimestamp, 60)).toBe(false); // TTL = 60s + }); +}); + +// ─── isSegwit ───────────────────────────────────────────────────────────────── + +describe('isSegwit', () => { + it('returns false for empty string', () => { + expect(isSegwit('')).toBe(false); + }); + + it('returns false for falsy value', () => { + expect(isSegwit(null as any)).toBe(false); + }); + + it('returns true for P2WPKH', () => { + expect(isSegwit('P2WPKH')).toBe(true); + }); + + it('returns true for P2WSH', () => { + expect(isSegwit('P2WSH')).toBe(true); + }); + + it('returns false for P2PKH', () => { + expect(isSegwit('P2PKH')).toBe(false); + }); + + it('returns false for P2SH', () => { + expect(isSegwit('P2SH')).toBe(false); + }); + + it('returns false for P2TR', () => { + expect(isSegwit('P2TR')).toBe(false); + }); +}); + +// ─── isTaproot ──────────────────────────────────────────────────────────────── + +describe('isTaproot', () => { + it('returns false for empty string', () => { + expect(isTaproot('')).toBe(false); + }); + + it('returns false for falsy value', () => { + expect(isTaproot(null as any)).toBe(false); + }); + + it('returns true for P2TR', () => { + expect(isTaproot('P2TR')).toBe(true); + }); + + it('returns false for P2WPKH', () => { + expect(isTaproot('P2WPKH')).toBe(false); + }); + + it('returns false for P2SH', () => { + expect(isTaproot('P2SH')).toBe(false); + }); +}); + +// ─── getRemainingWalletCount ─────────────────────────────────────────────────── + +describe('getRemainingWalletCount', () => { + it('returns undefined when wallets is undefined', () => { + expect(getRemainingWalletCount(undefined)).toBeUndefined(); + }); + + it('returns undefined when wallets length is less than WALLET_DISPLAY_LIMIT', () => { + const wallets = [makeWallet(), makeWallet()]; + expect(getRemainingWalletCount(wallets)).toBeUndefined(); + }); + + it('returns 0 when wallets length equals WALLET_DISPLAY_LIMIT (5)', () => { + const wallets = Array.from({length: 5}, () => makeWallet()); + // length === limit → not < limit, so returns length - limit = 0 + expect(getRemainingWalletCount(wallets)).toBe(0); + }); + + it('returns remaining count when wallets exceed WALLET_DISPLAY_LIMIT', () => { + const wallets = Array.from({length: 8}, () => makeWallet()); + expect(getRemainingWalletCount(wallets)).toBe(3); + }); +}); + +// ─── GetEstimatedTxSize ──────────────────────────────────────────────────────── + +describe('GetEstimatedTxSize', () => { + // wallet.m and wallet.n are read directly, not from credentials + it('returns a number > 0 for P2SH wallet', () => { + const wallet = makeWallet({ + m: 1, + n: 1, + credentials: makeCredentials({addressType: 'P2SH', n: 1, m: 1}), + }); + const result = GetEstimatedTxSize(wallet); + expect(typeof result).toBe('number'); + expect(result).toBeGreaterThan(0); + }); + + it('uses P2PKH size (147) for P2PKH address type', () => { + const walletP2PKH = makeWallet({ + m: 1, + n: 1, + credentials: makeCredentials({addressType: 'P2PKH', n: 1, m: 1}), + }); + const resultP2PKH = GetEstimatedTxSize(walletP2PKH); + const walletP2SH = makeWallet({ + m: 1, + n: 1, + credentials: makeCredentials({addressType: 'P2SH', n: 1, m: 1}), + }); + const resultP2SH = GetEstimatedTxSize(walletP2SH); + // P2PKH input is 147, P2SH default is calculated differently + expect(resultP2PKH).not.toBe(resultP2SH); + }); + + it('respects custom nbOutputs and nbInputs', () => { + const wallet = makeWallet({ + m: 1, + n: 1, + credentials: makeCredentials({addressType: 'P2SH', n: 1, m: 1}), + }); + const result1 = GetEstimatedTxSize(wallet, 2, 1); + const result2 = GetEstimatedTxSize(wallet, 4, 2); + expect(result2).toBeGreaterThan(result1); + }); +}); + +// ─── isMatch ────────────────────────────────────────────────────────────────── + +describe('isMatch', () => { + it('matches by fingerPrint when both have it', () => { + const key1 = {fingerPrint: 'fp-123'}; + const key2 = makeKey({properties: {fingerPrint: 'fp-123'}}); + expect(isMatch(key1, key2)).toBe(true); + }); + + it('does not match when fingerPrints differ', () => { + const key1 = {fingerPrint: 'fp-AAA'}; + const key2 = makeKey({properties: {fingerPrint: 'fp-BBB'}}); + expect(isMatch(key1, key2)).toBe(false); + }); + + it('falls back to id comparison when no fingerPrint', () => { + const key1 = {id: 'key-abc'}; + const key2 = makeKey({id: 'key-abc', properties: {}}); + expect(isMatch(key1, key2)).toBe(true); + }); + + it('id mismatch returns false', () => { + const key1 = {id: 'key-abc'}; + const key2 = makeKey({id: 'key-xyz', properties: {}}); + expect(isMatch(key1, key2)).toBe(false); + }); +}); + +// ─── getMatchedKey ──────────────────────────────────────────────────────────── + +describe('getMatchedKey', () => { + it('returns matching key', () => { + const key = makeKey({id: 'k1', properties: {fingerPrint: 'fp-1'}}); + const keyToMatch = {fingerPrint: 'fp-1'}; + expect(getMatchedKey(keyToMatch, [key])).toBe(key); + }); + + it('returns undefined when no match', () => { + const key = makeKey({properties: {fingerPrint: 'fp-999'}}); + expect(getMatchedKey({fingerPrint: 'fp-0'}, [key])).toBeUndefined(); + }); + + it('returns undefined for empty keys array', () => { + expect(getMatchedKey({id: 'k1'}, [])).toBeUndefined(); + }); +}); + +// ─── getReadOnlyKey ─────────────────────────────────────────────────────────── + +describe('getReadOnlyKey', () => { + it('returns key with id containing "readonly"', () => { + const key = makeKey({id: 'readonly/ledger'}); + expect(getReadOnlyKey([key])).toBe(key); + }); + + it('returns undefined when no readonly key', () => { + const key = makeKey({id: 'some-regular-key'}); + expect(getReadOnlyKey([key])).toBeUndefined(); + }); + + it('returns undefined for empty array', () => { + expect(getReadOnlyKey([])).toBeUndefined(); + }); +}); + +// ─── isMatchedWallet ────────────────────────────────────────────────────────── + +describe('isMatchedWallet', () => { + it('returns matching wallet when walletId matches', () => { + const existing = makeWallet({ + credentials: makeCredentials({walletId: 'w-match'}), + }); + const newWallet = makeWallet({ + credentials: makeCredentials({walletId: 'w-match'}), + }); + expect(isMatchedWallet(newWallet, [existing])).toBe(existing); + }); + + it('returns undefined when no match', () => { + const existing = makeWallet({ + credentials: makeCredentials({walletId: 'w-1'}), + }); + const newWallet = makeWallet({ + credentials: makeCredentials({walletId: 'w-2'}), + }); + expect(isMatchedWallet(newWallet, [existing])).toBeUndefined(); + }); +}); + +// ─── getEVMAccountName ──────────────────────────────────────────────────────── + +describe('getEVMAccountName', () => { + it('returns undefined when wallet has no keyId', () => { + const wallet = makeWallet({keyId: undefined}); + expect(getEVMAccountName(wallet, {})).toBeUndefined(); + }); + + it('returns undefined when wallet has no receiveAddress', () => { + const wallet = makeWallet({receiveAddress: undefined}); + expect(getEVMAccountName(wallet, {})).toBeUndefined(); + }); + + it('returns undefined when key has no evmAccountsInfo', () => { + const wallet = makeWallet(); + const keys: any = {'key-1': makeKey({evmAccountsInfo: undefined})}; + expect(getEVMAccountName(wallet, keys)).toBeUndefined(); + }); + + it('returns undefined when address not in evmAccountsInfo', () => { + const wallet = makeWallet({receiveAddress: '0xABC'}); + const keys: any = {'key-1': makeKey({evmAccountsInfo: {}})}; + expect(getEVMAccountName(wallet, keys)).toBeUndefined(); + }); + + it('returns name from evmAccountsInfo', () => { + const wallet = makeWallet({receiveAddress: '0xABC'}); + const keys: any = { + 'key-1': makeKey({evmAccountsInfo: {'0xABC': {name: 'Main Account'}}}), + }; + expect(getEVMAccountName(wallet, keys)).toBe('Main Account'); + }); +}); + +// ─── generateKeyExportCode ──────────────────────────────────────────────────── + +describe('generateKeyExportCode', () => { + it('generates export code string in expected format', () => { + const key = makeKey({properties: {mnemonicHasPassphrase: false}}); + const mnemonic = 'word1 word2 word3'; + const result = generateKeyExportCode(key, mnemonic); + expect(result).toBe(`1|${mnemonic}|null|null|false|null`); + }); + + it('includes mnemonicHasPassphrase = true', () => { + const key = makeKey({properties: {mnemonicHasPassphrase: true}}); + const result = generateKeyExportCode(key, 'abc'); + expect(result).toContain('true'); + }); +}); + +// ─── checkEncryptPassword ───────────────────────────────────────────────────── + +describe('checkEncryptPassword', () => { + it('calls checkPassword on key.methods', () => { + const mockCheckPassword = jest.fn(() => true); + const key = makeKey({ + methods: {...makeKey().methods, checkPassword: mockCheckPassword}, + }); + const result = checkEncryptPassword(key, 'secret'); + expect(mockCheckPassword).toHaveBeenCalledWith('secret'); + expect(result).toBe(true); + }); + + it('returns false when methods is undefined', () => { + const key = makeKey({methods: undefined}); + // Should not throw + expect(checkEncryptPassword(key, 'secret')).toBeUndefined(); + }); +}); + +// ─── checkPrivateKeyEncrypted ───────────────────────────────────────────────── + +describe('checkPrivateKeyEncrypted', () => { + it('returns true when key is encrypted', () => { + const key = makeKey({ + methods: {...makeKey().methods, isPrivKeyEncrypted: jest.fn(() => true)}, + }); + expect(checkPrivateKeyEncrypted(key)).toBe(true); + }); + + it('returns false when key is not encrypted', () => { + const key = makeKey(); + expect(checkPrivateKeyEncrypted(key)).toBe(false); + }); +}); + +// ─── formatCryptoAmount ──────────────────────────────────────────────────────── + +describe('formatCryptoAmount', () => { + it('returns "0" for zero amount', () => { + expect(formatCryptoAmount(0, 'btc')).toBe('0'); + }); + + it('calls BwcProvider formatAmount for non-zero amount', () => { + const {BwcProvider} = require('../../../lib/bwc'); + const instance = BwcProvider.getInstance(); + const result = formatCryptoAmount(100000, 'btc'); + expect(instance.getUtils).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); +}); + +// ─── buildKeyObj ────────────────────────────────────────────────────────────── + +describe('buildKeyObj', () => { + it('builds key with id from key.id when key is present', () => { + const keyMethods: any = { + id: 'my-key-id', + isPrivKeyEncrypted: jest.fn(() => false), + toObj: jest.fn(() => ({})), + }; + const result = buildKeyObj({key: keyMethods, wallets: []}); + expect(result.id).toBe('my-key-id'); + expect(result.keyName).toBe('My Key'); + }); + + it('builds readonly id when key is undefined and no hardwareSource', () => { + const result = buildKeyObj({key: undefined, wallets: []}); + expect(result.id).toBe('readonly'); + expect(result.keyName).toBe('Read Only'); + expect(result.isReadOnly).toBe(true); + }); + + it('builds readonly/hardwareSource id when hardwareSource is provided and no key', () => { + const result = buildKeyObj({ + key: undefined, + wallets: [], + hardwareSource: 'ledger', + }); + expect(result.id).toBe('readonly/ledger'); + expect(result.keyName).toBe('My Ledger'); + }); + + it('uses custom keyName when provided', () => { + const keyMethods: any = { + id: 'k-1', + isPrivKeyEncrypted: jest.fn(() => false), + toObj: jest.fn(() => ({})), + }; + const result = buildKeyObj({ + key: keyMethods, + wallets: [], + keyName: 'Custom Name', + }); + expect(result.keyName).toBe('Custom Name'); + }); + + it('stores totalBalance and backupComplete', () => { + const keyMethods: any = { + id: 'k-2', + isPrivKeyEncrypted: jest.fn(() => false), + toObj: jest.fn(() => ({})), + }; + const result = buildKeyObj({ + key: keyMethods, + wallets: [], + totalBalance: 500, + backupComplete: true, + }); + expect(result.totalBalance).toBe(500); + expect(result.backupComplete).toBe(true); + }); +}); + +// ─── buildTssKeyObj ─────────────────────────────────────────────────────────── + +describe('buildTssKeyObj', () => { + it('builds a key obj from a tssKey', () => { + const tssKey: any = { + id: 'tss-key-id', + toObj: jest.fn(() => ({ + privateKeyShare: 'secret', + metadata: {m: 1, n: 2}, + })), + isPrivKeyEncrypted: jest.fn(() => false), + metadata: {m: 1, n: 2}, + }; + const result = buildTssKeyObj({tssKey, wallets: []}); + expect(result.id).toBe('tss-key-id'); + // privateKeyShare should be deleted from properties + expect((result.properties as any)?.privateKeyShare).toBeUndefined(); + }); + + it('uses custom keyName when provided', () => { + const tssKey: any = { + id: 'tss-key-id', + toObj: jest.fn(() => ({metadata: {m: 2, n: 3}})), + isPrivKeyEncrypted: jest.fn(() => false), + metadata: {m: 2, n: 3}, + }; + const result = buildTssKeyObj({ + tssKey, + wallets: [], + keyName: 'Custom TSS Key', + }); + expect(result.keyName).toBe('Custom TSS Key'); + }); + + it('defaults keyName to TSS Key format when not provided', () => { + const tssKey: any = { + id: 'tss-key-id', + toObj: jest.fn(() => ({metadata: {m: 2, n: 3}})), + isPrivKeyEncrypted: jest.fn(() => false), + metadata: {m: 2, n: 3}, + }; + const result = buildTssKeyObj({tssKey, wallets: []}); + expect(result.keyName).toContain('TSS Key'); + expect(result.keyName).toContain('2-of-3'); + }); +}); + +// ─── buildMigrationKeyObj ───────────────────────────────────────────────────── + +describe('buildMigrationKeyObj', () => { + it('builds key from migration key', () => { + const key: any = { + id: 'migrated-id', + methods: { + isPrivKeyEncrypted: jest.fn(() => false), + toObj: jest.fn(() => ({})), + }, + }; + const result = buildMigrationKeyObj({ + key, + wallets: [], + backupComplete: true, + keyName: 'Migrated Key', + }); + expect(result.id).toBe('migrated-id'); + expect(result.keyName).toBe('Migrated Key'); + expect(result.backupComplete).toBe(true); + }); +}); + +// ─── coinbaseAccountToWalletRow ─────────────────────────────────────────────── + +describe('coinbaseAccountToWalletRow', () => { + const makeCoinbaseAccount = (overrides: any = {}): any => ({ + id: 'cb-account-1', + name: 'My BTC Wallet', + currency: {code: 'BTC', name: 'Bitcoin'}, + balance: {amount: '0.5', currency: 'BTC'}, + ...overrides, + }); + + const makeExchangeRates = (): any => ({ + data: {currency: 'BTC', rates: {USD: '50000'}}, + }); + + it('returns a WalletRowProps object', () => { + const account = makeCoinbaseAccount(); + const result = coinbaseAccountToWalletRow( + account, + makeExchangeRates(), + 'USD', + ); + expect(result.id).toBe('cb-account-1'); + expect(result.currencyAbbreviation).toBe('BTC'); + expect(result.network).toBe(Network.mainnet); + }); + + it('sets cryptoBalance to "0" when balance amount is 0', () => { + const account = makeCoinbaseAccount({ + balance: {amount: '0', currency: 'BTC'}, + }); + const result = coinbaseAccountToWalletRow( + account, + makeExchangeRates(), + 'USD', + ); + expect(result.cryptoBalance).toBe('0'); + }); + + it('returns the balance amount when non-zero', () => { + const account = makeCoinbaseAccount({ + balance: {amount: '1.23', currency: 'BTC'}, + }); + const result = coinbaseAccountToWalletRow( + account, + makeExchangeRates(), + 'USD', + ); + expect(result.cryptoBalance).toBe('1.23'); + }); + + it('assigns chain = "eth" for unknown coin codes', () => { + const account = makeCoinbaseAccount({ + currency: {code: 'UNKNOWN_COIN_XYZ', name: 'Unknown'}, + balance: {amount: '1', currency: 'UNKNOWN_COIN_XYZ'}, + }); + const result = coinbaseAccountToWalletRow(account, null, 'USD'); + expect(result.chain).toBe('eth'); + }); + + it('assigns chain = "btc" for known UTXO coin (BTC)', () => { + const account = makeCoinbaseAccount(); + const result = coinbaseAccountToWalletRow(account, null, 'USD'); + expect(result.chain).toBe('btc'); + }); +}); + +// ─── BuildCoinbaseWalletsList ───────────────────────────────────────────────── + +describe('BuildCoinbaseWalletsList', () => { + const makeCoinbaseAccount = (amount = '1.0'): any => ({ + id: 'cb-1', + name: 'BTC', + currency: {code: 'BTC', name: 'Bitcoin'}, + balance: {amount, currency: 'BTC'}, + }); + + const makeCoinbaseUser = (): any => ({ + data: {id: 'user-1', name: 'Test User'}, + }); + + it('returns empty array when coinbaseAccounts is null', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: null, + coinbaseExchangeRates: null, + coinbaseUser: makeCoinbaseUser(), + skipThreshold: true, + }); + expect(result).toEqual([]); + }); + + it('returns empty array when coinbaseUser is null', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: [makeCoinbaseAccount()], + coinbaseExchangeRates: null, + coinbaseUser: null, + skipThreshold: true, + }); + expect(result).toEqual([]); + }); + + it('returns empty array when coinbaseExchangeRates is null', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: [makeCoinbaseAccount()], + coinbaseExchangeRates: null, + coinbaseUser: makeCoinbaseUser(), + skipThreshold: true, + }); + expect(result).toEqual([]); + }); + + it('returns empty array when network is testnet', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: [makeCoinbaseAccount()], + coinbaseExchangeRates: { + data: {currency: 'BTC', rates: {USD: '50000'}}, + } as any, + coinbaseUser: makeCoinbaseUser(), + network: Network.testnet, + skipThreshold: true, + }); + expect(result).toEqual([]); + }); + + it('returns wallets list when all conditions met with skipThreshold', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: [makeCoinbaseAccount('0.5')], + coinbaseExchangeRates: { + data: {currency: 'BTC', rates: {USD: '50000'}}, + } as any, + coinbaseUser: makeCoinbaseUser(), + skipThreshold: true, + }); + // Account has balance > 0 and skipThreshold is true + expect(result.length).toBe(1); + expect(result[0].keyName).toContain('Test User'); + }); + + it('returns empty array when account balance is 0', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: [makeCoinbaseAccount('0')], + coinbaseExchangeRates: { + data: {currency: 'BTC', rates: {USD: '50000'}}, + } as any, + coinbaseUser: makeCoinbaseUser(), + skipThreshold: true, + }); + // Balance is 0, so filtered out → coinbaseAccounts[] is empty → filtered key removed + expect(result).toEqual([]); + }); + + it('returns empty array when enabled is false (no invoice, no skipThreshold)', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: [makeCoinbaseAccount('0.5')], + coinbaseExchangeRates: { + data: {currency: 'BTC', rates: {USD: '50000'}}, + } as any, + coinbaseUser: makeCoinbaseUser(), + skipThreshold: false, + // no invoice → enabled = false + }); + expect(result).toEqual([]); + }); + + it('filters by paymentOptions when payProOptions are provided', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: [makeCoinbaseAccount('0.5')], + coinbaseExchangeRates: { + data: {currency: 'BTC', rates: {USD: '50000'}}, + } as any, + coinbaseUser: makeCoinbaseUser(), + skipThreshold: true, + payProOptions: { + paymentOptions: [ + {currency: 'BTC', network: Network.mainnet, selected: true} as any, + ], + } as any, + }); + // BTC matches, so list should have one entry + expect(result.length).toBe(1); + }); + + it('filters out accounts that do not match paymentOptions', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: [makeCoinbaseAccount('0.5')], + coinbaseExchangeRates: { + data: {currency: 'BTC', rates: {USD: '50000'}}, + } as any, + coinbaseUser: makeCoinbaseUser(), + skipThreshold: true, + payProOptions: { + paymentOptions: [ + {currency: 'ETH', network: Network.mainnet, selected: true} as any, + ], + } as any, + }); + // BTC account does not match ETH payment option + expect(result).toEqual([]); + }); + + it('includes account when invoice threshold met', () => { + const result = BuildCoinbaseWalletsList({ + coinbaseAccounts: [makeCoinbaseAccount('0.5')], + coinbaseExchangeRates: { + data: {currency: 'BTC', rates: {USD: '50000'}}, + } as any, + coinbaseUser: makeCoinbaseUser(), + skipThreshold: false, + invoice: { + price: 10, + oauth: {coinbase: {enabled: true, threshold: 20}}, + } as any, + }); + // enabled=true, threshold(20) >= price(10) + expect(result.length).toBe(1); + }); +}); + +// ─── mapAbbreviationAndName ─────────────────────────────────────────────────── + +describe('mapAbbreviationAndName', () => { + const makeDispatch = () => (effect: any) => { + // Simulate dispatch returning a string name from GetName + if (typeof effect === 'function') { + return effect( + () => {}, + () => ({WALLET: {customTokenDataByAddress: {}}}), + ); + } + return effect; + }; + + it('maps pax to usdp', () => { + const dispatch = makeDispatch() as any; + const result = (mapAbbreviationAndName('pax', 'eth', undefined) as any)( + dispatch, + ); + expect(result.currencyAbbreviation).toBe('usdp'); + }); + + it('maps matic to pol', () => { + const dispatch = makeDispatch() as any; + const result = (mapAbbreviationAndName('matic', 'matic', undefined) as any)( + dispatch, + ); + expect(result.currencyAbbreviation).toBe('pol'); + }); + + it('passes through unknown coin unchanged', () => { + const dispatch = makeDispatch() as any; + const result = (mapAbbreviationAndName('btc', 'btc', undefined) as any)( + dispatch, + ); + expect(result.currencyAbbreviation).toBe('btc'); + }); +}); + +// ─── GetProtocolPrefixAddress ───────────────────────────────────────────────── + +describe('GetProtocolPrefixAddress', () => { + const dispatchFn = (effect: any) => { + if (typeof effect === 'function') { + return effect( + () => {}, + () => ({WALLET: {customTokenDataByAddress: {}}}), + ); + } + return effect; + }; + + it('returns address unchanged for non-bch coin', () => { + const result = ( + GetProtocolPrefixAddress('btc', 'mainnet', '1ABCxyz', 'btc') as any + )(dispatchFn as any); + expect(result).toBe('1ABCxyz'); + }); + + it('prefixes address for bch coin', () => { + const result = ( + GetProtocolPrefixAddress('bch', 'livenet', 'qABC', 'bch') as any + )(dispatchFn as any); + // GetProtocolPrefix returns the prefix from BitpaySupportedCoins for bch/livenet + expect(typeof result).toBe('string'); + expect(result).toContain('qABC'); + expect(result).toContain(':'); + }); +}); + +// ─── toFiat ─────────────────────────────────────────────────────────────────── + +describe('toFiat', () => { + // toFiat is an Effect — we call it with a dispatch that calls real GetPrecision + const makeGetState = + (customTokenData = {}) => + () => ({ + WALLET: {customTokenDataByAddress: customTokenData}, + }); + + const makeRates = (): any => ({ + btc: [{code: 'USD', fetchedOn: 0, name: 'US Dollar', rate: 50000, ts: 0}], + }); + + it('returns 0 when ratesPerCurrency is not found', () => { + const dispatch = (effect: any) => { + if (typeof effect === 'function') return effect(dispatch, makeGetState()); + return effect; + }; + const result = (toFiat(100000, 'USD', 'btc', 'btc', {}, undefined) as any)( + dispatch as any, + ); + expect(result).toBe(0); + }); + + it('returns 0 when fiatCode rate is not found', () => { + const dispatch = (effect: any) => { + if (typeof effect === 'function') return effect(dispatch, makeGetState()); + return effect; + }; + const rates = makeRates(); + const result = ( + toFiat(100000, 'EUR', 'btc', 'btc', rates, undefined) as any + )(dispatch as any); + expect(result).toBe(0); + }); + + it('returns numeric fiat amount when rate and precision are found', () => { + const dispatch = (effect: any) => { + if (typeof effect === 'function') return effect(dispatch, makeGetState()); + return effect; + }; + const rates = makeRates(); + // 100000000 sat * (1/1e8) * 50000 = 50000 USD + const result = ( + toFiat(100000000, 'USD', 'btc', 'btc', rates, undefined) as any + )(dispatch as any); + expect(typeof result).toBe('number'); + expect(result).toBeCloseTo(50000, 0); + }); + + it('uses customRate when provided and precision available', () => { + const dispatch = (effect: any) => { + if (typeof effect === 'function') return effect(dispatch, makeGetState()); + return effect; + }; + const result = ( + toFiat(100000000, 'USD', 'btc', 'btc', {}, undefined, 30000) as any + )(dispatch as any); + expect(typeof result).toBe('number'); + expect(result).toBeCloseTo(30000, 0); + }); + + it('returns 0 when rate value is 0', () => { + const dispatch = (effect: any) => { + if (typeof effect === 'function') return effect(dispatch, makeGetState()); + return effect; + }; + const rates: any = { + btc: [{code: 'USD', fetchedOn: 0, name: 'US Dollar', rate: 0, ts: 0}], + }; + const result = ( + toFiat(100000, 'USD', 'btc', 'btc', rates, undefined) as any + )(dispatch as any); + expect(result).toBe(0); + }); +}); + +// ─── findMatchedKeyAndUpdate ────────────────────────────────────────────────── + +describe('findMatchedKeyAndUpdate', () => { + it('returns original key and wallets when opts.keyId is set', () => { + const key = {fingerPrint: 'fp-1'}; + const wallets: any[] = []; + const keys: any[] = []; + const result = findMatchedKeyAndUpdate(wallets, key, keys, { + keyId: 'some-key-id', + }); + expect(result.key).toBe(key); + expect(result.wallets).toBe(wallets); + expect(result.keyName).toBeUndefined(); + }); + + it('returns original key when no matched key found', () => { + const key = {fingerPrint: 'fp-unknown'}; + const wallets: any[] = []; + const existingKey = makeKey({properties: {fingerPrint: 'fp-other'}}); + const result = findMatchedKeyAndUpdate(wallets, key, [existingKey], {}); + expect(result.key).toBe(key); + expect(result.keyName).toBeUndefined(); + }); + + it('updates wallets keyId when matched key is found', () => { + const matchedKey = makeKey({ + id: 'matched-key-id', + properties: {fingerPrint: 'fp-match'}, + wallets: [], + keyName: 'Matched Key', + }); + const wallet: any = { + credentials: {walletId: 'w1', keyId: 'old-key', walletName: 'Wallet 1'}, + keyId: 'old-key', + }; + const key = {fingerPrint: 'fp-match'}; + const result = findMatchedKeyAndUpdate([wallet], key, [matchedKey], {}); + expect(result.keyName).toBe('Matched Key'); + expect(wallet.keyId).toBe('matched-key-id'); + expect(wallet.credentials.keyId).toBe('matched-key-id'); + }); + + it('preserves existing walletName when found in matchedKey.wallets', () => { + const matchedKey = makeKey({ + id: 'mk-1', + properties: {fingerPrint: 'fp-x'}, + wallets: [{id: 'w1', walletName: 'Preserved Name'}], + keyName: 'Key', + }); + const wallet: any = { + credentials: {walletId: 'w1', keyId: 'old', walletName: 'Old Name'}, + keyId: 'old', + }; + findMatchedKeyAndUpdate([wallet], {fingerPrint: 'fp-x'}, [matchedKey], {}); + expect(wallet.credentials.walletName).toBe('Preserved Name'); + }); +}); + +// ─── findKeyByKeyId ─────────────────────────────────────────────────────────── + +describe('findKeyByKeyId', () => { + it('resolves with the matching key', async () => { + const key1 = makeKey({id: 'k1'}); + const key2 = makeKey({id: 'k2'}); + const keys: any = {k1: key1, k2: key2}; + const result = await findKeyByKeyId('k2', keys); + expect(result).toBe(key2); + }); + + it('resolves with undefined when no key matches', async () => { + const key1 = makeKey({id: 'k1'}); + const keys: any = {k1: key1}; + // Promise.all resolves, but resolve is never called with a key → resolves with undefined + // The implementation never rejects if nothing matches — timeout guard + const result = await Promise.race([ + findKeyByKeyId('no-match', keys).catch(() => 'caught'), + new Promise(resolve => setTimeout(() => resolve('timeout'), 100)), + ]); + // Either never resolved (timeout) or caught — either way no throw + expect(['timeout', 'caught', undefined].includes(result as any)).toBe(true); + }); +}); + +// ─── getAllWalletClients ─────────────────────────────────────────────────────── + +describe('getAllWalletClients', () => { + it('resolves with wallet clients for wallets that pass filters', async () => { + const wallet1: any = makeWallet({ + credentials: makeCredentials({ + token: undefined, + isComplete: jest.fn(() => true), + }), + pendingTssSession: undefined, + }); + const wallet2: any = makeWallet({ + credentials: makeCredentials({ + token: {address: '0xtoken'}, + isComplete: jest.fn(() => true), + }), + pendingTssSession: undefined, + }); + const keys: any = { + 'key-1': {wallets: [wallet1, wallet2]}, + }; + const result = await getAllWalletClients(keys); + // wallet2 has a token so it is filtered out + expect(result).toContain(wallet1); + expect(result).not.toContain(wallet2); + }); + + it('filters out wallets with pendingTssSession', async () => { + const wallet: any = makeWallet({ + credentials: makeCredentials({ + token: undefined, + isComplete: jest.fn(() => true), + }), + pendingTssSession: true, + }); + const keys: any = {'key-1': {wallets: [wallet]}}; + const result = await getAllWalletClients(keys); + expect(result).toHaveLength(0); + }); + + it('filters out wallets that are not complete', async () => { + const wallet: any = makeWallet({ + credentials: makeCredentials({ + token: undefined, + isComplete: jest.fn(() => false), + }), + pendingTssSession: undefined, + }); + const keys: any = {'key-1': {wallets: [wallet]}}; + const result = await getAllWalletClients(keys); + expect(result).toHaveLength(0); + }); + + it('resolves with empty array when keys is empty', async () => { + const result = await getAllWalletClients({}); + expect(result).toEqual([]); + }); +}); + +// ─── findWalletByIdHashed ───────────────────────────────────────────────────── + +describe('findWalletByIdHashed', () => { + const makeCompleteWallet = (walletId: string) => + makeWallet({ + id: walletId, + keyId: 'key-1', + credentials: makeCredentials({ + walletId, + token: undefined, + isComplete: jest.fn(() => true), + }), + pendingTssSession: undefined, + }); + + it('resolves with wallet when hashed walletId matches', async () => { + const crypto = require('crypto'); + const walletId = 'wallet-abc-123'; + const hash = crypto.createHash('sha256'); + hash.update(walletId); + const hashed = hash.digest('hex'); + + const wallet = makeCompleteWallet(walletId); + const keys: any = {'key-1': {wallets: [wallet]}}; + const result = await findWalletByIdHashed(keys, hashed, null, undefined); + expect(result.wallet).toBeDefined(); + expect(result.keyId).toBe('key-1'); + }); + + it('resolves with undefined wallet when no hash matches', async () => { + const wallet = makeCompleteWallet('wallet-xyz'); + const keys: any = {'key-1': {wallets: [wallet]}}; + const result = await findWalletByIdHashed( + keys, + 'deadbeefdeadbeef', + null, + undefined, + ); + expect(result.wallet).toBeUndefined(); + expect(result.keyId).toBeUndefined(); + }); + + it('uses walletId without token suffix when tokenAddress provided', async () => { + const crypto = require('crypto'); + // walletId has a token suffix after last hyphen + const walletIdBase = 'wallet-main'; + const walletId = `${walletIdBase}-tokenpart`; + const hash = crypto.createHash('sha256'); + hash.update(walletIdBase); + const hashed = hash.digest('hex'); + + const wallet = makeCompleteWallet(walletId); + const keys: any = {'key-1': {wallets: [wallet]}}; + const result = await findWalletByIdHashed( + keys, + hashed, + '0xSomeToken', + undefined, + ); + expect(result.wallet).toBeDefined(); + }); +}); + +// ─── buildUIFormattedWallet ─────────────────────────────────────────────────── + +describe('buildUIFormattedWallet', () => { + const makeDispatch = () => (effect: any) => { + if (typeof effect === 'function') { + return effect(makeDispatch(), () => ({ + WALLET: {customTokenDataByAddress: {}}, + })); + } + return effect; + }; + + const makeRates = (): any => ({ + btc: [{code: 'USD', fetchedOn: 0, name: 'US Dollar', rate: 50000, ts: 0}], + }); + + const makeFullWallet = (overrides: any = {}): any => ({ + ...makeWallet(overrides), + balance: makeBalance({ + sat: 100000000, + satLocked: 0, + satConfirmedLocked: 0, + satSpendable: 100000000, + satPending: 0, + }), + credentials: makeCredentials({ + n: 1, + m: 1, + account: 0, + walletName: 'BTC Wallet', + isComplete: jest.fn(() => true), + }), + chain: 'btc', + currencyAbbreviation: 'btc', + currencyName: 'Bitcoin', + chainName: 'Bitcoin', + tokenAddress: undefined, + network: Network.mainnet, + isScanning: false, + hideWallet: false, + hideWalletByAccount: false, + hideBalance: false, + walletName: 'BTC Wallet', + tssMetadata: undefined, + }); + + it('builds a WalletRowProps from wallet data', () => { + const dispatch = makeDispatch() as any; + const wallet = makeFullWallet(); + const result = buildUIFormattedWallet(wallet, 'USD', makeRates(), dispatch); + expect(result.id).toBe('wallet-1'); + expect(result.currencyAbbreviation).toBeDefined(); + expect(result.network).toBe(Network.mainnet); + expect(result.chain).toBe('btc'); + }); + + it('includes fiat balance calculations when skipFiatCalculations is false', () => { + const dispatch = makeDispatch() as any; + const wallet = makeFullWallet(); + const result = buildUIFormattedWallet( + wallet, + 'USD', + makeRates(), + dispatch, + undefined, + false, + ); + expect(result.fiatBalance).toBeDefined(); + expect(typeof result.fiatBalance).toBe('number'); + }); + + it('skips fiat calculations when skipFiatCalculations is true', () => { + const dispatch = makeDispatch() as any; + const wallet = makeFullWallet(); + const result = buildUIFormattedWallet( + wallet, + 'USD', + makeRates(), + dispatch, + undefined, + true, + ); + expect(result.fiatBalance).toBeUndefined(); + }); + + it('sets multisig string when credentials.n > 1', () => { + const dispatch = makeDispatch() as any; + // credentials.n must be > 1; buildUIFormattedWallet destructures credentials from wallet + const wallet: any = { + ...makeFullWallet(), + credentials: makeCredentials({ + n: 3, + m: 2, + account: 0, + walletName: 'Multisig', + isComplete: jest.fn(() => true), + }), + }; + const result = buildUIFormattedWallet( + wallet, + 'USD', + {}, + dispatch, + undefined, + true, + ); + expect(result.multisig).toContain('2/3'); + }); + + it('sets threshold string when tssMetadata is present', () => { + const dispatch = makeDispatch() as any; + const wallet: any = { + ...makeFullWallet(), + tssMetadata: {id: 'tss-1', n: 3, m: 2, partyId: 1}, + }; + const result = buildUIFormattedWallet( + wallet, + 'USD', + {}, + dispatch, + undefined, + true, + ); + expect(result.threshold).toContain('2/3'); + }); +}); + +// ─── buildAccountList ───────────────────────────────────────────────────────── + +describe('buildAccountList', () => { + const makeDispatch = () => (effect: any) => { + if (typeof effect === 'function') { + return effect(makeDispatch(), () => ({ + WALLET: {customTokenDataByAddress: {}}, + })); + } + return effect; + }; + + const makeBtcWallet = (overrides: any = {}): any => ({ + ...makeWallet(), + balance: makeBalance({ + sat: 100000000, + satLocked: 0, + satConfirmedLocked: 0, + satSpendable: 100000000, + satPending: 0, + }), + credentials: makeCredentials({ + n: 1, + m: 1, + account: 0, + walletName: 'BTC', + isComplete: jest.fn(() => true), + }), + chain: 'btc', + currencyAbbreviation: 'btc', + currencyName: 'Bitcoin', + chainName: 'Bitcoin', + tokenAddress: undefined, + network: Network.mainnet, + isScanning: false, + hideWallet: false, + hideWalletByAccount: false, + hideBalance: false, + walletName: 'BTC', + tssMetadata: undefined, + receiveAddress: '1ABCxyz', + ...overrides, + }); + + const makeKey2 = (wallets: any[] = []): any => ({ + ...makeKey(), + wallets, + }); + + it('returns empty array when key has no wallets', () => { + const dispatch = makeDispatch() as any; + const key = makeKey2([]); + const result = buildAccountList(key, 'USD', {}, dispatch, { + skipFiatCalculations: true, + }); + expect(result).toEqual([]); + }); + + it('builds account list from wallets', () => { + const dispatch = makeDispatch() as any; + const wallet = makeBtcWallet(); + const key = makeKey2([wallet]); + const result = buildAccountList(key, 'USD', {}, dispatch, { + skipFiatCalculations: true, + }); + expect(result.length).toBeGreaterThan(0); + expect(result[0].chains).toContain('btc'); + }); + + it('filters out wallets where hideWallet is true when filterByHideWallet=true', () => { + const dispatch = makeDispatch() as any; + const wallet = makeBtcWallet({hideWallet: true}); + const key = makeKey2([wallet]); + const result = buildAccountList(key, 'USD', {}, dispatch, { + skipFiatCalculations: true, + filterByHideWallet: true, + }); + expect(result).toHaveLength(0); + }); + + it('filters out wallets with no balance when filterWalletsByBalance=true', () => { + const dispatch = makeDispatch() as any; + const wallet = makeBtcWallet({ + balance: makeBalance({sat: 0}), + }); + const key = makeKey2([wallet]); + const result = buildAccountList(key, 'USD', {}, dispatch, { + skipFiatCalculations: true, + filterWalletsByBalance: true, + }); + expect(result).toHaveLength(0); + }); + + it('filters by chain when filterWalletsByChain=true and chain matches', () => { + const dispatch = makeDispatch() as any; + const wallet = makeBtcWallet(); + const key = makeKey2([wallet]); + const result = buildAccountList(key, 'USD', {}, dispatch, { + skipFiatCalculations: true, + filterWalletsByChain: true, + chain: 'btc', + }); + expect(result.length).toBe(1); + }); + + it('filters out wallet when chain does not match', () => { + const dispatch = makeDispatch() as any; + const wallet = makeBtcWallet(); + const key = makeKey2([wallet]); + const result = buildAccountList(key, 'USD', {}, dispatch, { + skipFiatCalculations: true, + filterWalletsByChain: true, + chain: 'eth', + }); + expect(result).toHaveLength(0); + }); + + it('filters by network using paymentOptions when filterWalletsByPaymentOptions=true', () => { + const dispatch = makeDispatch() as any; + const wallet = makeBtcWallet({network: Network.mainnet}); + const key = makeKey2([wallet]); + const result = buildAccountList(key, 'USD', {}, dispatch, { + skipFiatCalculations: true, + filterWalletsByPaymentOptions: true, + paymentOptions: [{network: Network.mainnet} as any], + }); + expect(result.length).toBe(1); + }); + + it('merges wallets sharing the same receiveAddress into one account', () => { + const dispatch = makeDispatch() as any; + const wallet1 = makeBtcWallet({ + receiveAddress: 'shared-addr', + chain: 'btc', + id: 'w1', + }); + const wallet2 = makeBtcWallet({ + receiveAddress: 'shared-addr', + chain: 'eth', + id: 'w2', + credentials: makeCredentials({ + n: 1, + m: 1, + account: 0, + walletId: 'wallet-2', + walletName: 'ETH', + isComplete: jest.fn(() => true), + }), + currencyAbbreviation: 'eth', + currencyName: 'Ethereum', + chainName: 'Ethereum', + }); + const key = makeKey2([wallet1, wallet2]); + const result = buildAccountList(key, 'USD', {}, dispatch, { + skipFiatCalculations: true, + }); + // Both wallets share same receiveAddress → merged into one account + expect(result.length).toBe(1); + expect(result[0].wallets.length).toBe(2); + }); + + it('uses filterByCustomWallets when provided', () => { + const dispatch = makeDispatch() as any; + const wallet1 = makeBtcWallet({id: 'w1'}); + const wallet2 = makeBtcWallet({ + id: 'w2', + receiveAddress: '2ABCxyz', + credentials: makeCredentials({ + walletId: 'wallet-2', + n: 1, + m: 1, + account: 0, + walletName: 'BTC2', + isComplete: jest.fn(() => true), + }), + }); + const key = makeKey2([wallet1, wallet2]); + // Only pass wallet1 as custom filter + const result = buildAccountList(key, 'USD', {}, dispatch, { + skipFiatCalculations: true, + filterByCustomWallets: [wallet1], + }); + expect(result.length).toBe(1); + }); +}); + +// ─── buildAssetsByChain ─────────────────────────────────────────────────────── + +describe('buildAssetsByChain', () => { + const makeWalletRow = (chain: string, fiatBalance = 0): any => ({ + id: `wallet-${chain}`, + chain, + chainName: chain.toUpperCase(), + img: '', + badgeImg: '', + currencyAbbreviation: chain, + fiatBalance, + fiatLockedBalance: 0, + fiatConfirmedLockedBalance: 0, + fiatSpendableBalance: 0, + fiatPendingBalance: 0, + receiveAddress: '0xaddr', + }); + + const makeAccountRow = (wallets: any[]): any => ({ + id: 'account-1', + keyId: 'key-1', + chains: wallets.map(w => w.chain), + wallets, + accountName: 'Test Account', + accountNumber: 0, + receiveAddress: '0xaddr', + isMultiNetworkSupported: true, + fiatBalance: 100, + fiatLockedBalance: 0, + fiatConfirmedLockedBalance: 0, + fiatSpendableBalance: 100, + fiatPendingBalance: 0, + fiatBalanceFormat: '$100.00', + fiatLockedBalanceFormat: '$0.00', + fiatConfirmedLockedBalanceFormat: '$0.00', + fiatSpendableBalanceFormat: '$100.00', + fiatPendingBalanceFormat: '$0.00', + }); + + it('returns empty array for account with no wallets', () => { + const account = makeAccountRow([]); + const result = buildAssetsByChain(account, 'USD'); + expect(result).toEqual([]); + }); + + it('groups wallets by chain', () => { + const wallets = [makeWalletRow('btc', 50), makeWalletRow('eth', 100)]; + const account = makeAccountRow(wallets); + const result = buildAssetsByChain(account, 'USD'); + expect(result.length).toBe(2); + const chains = result.map(r => r.chain); + expect(chains).toContain('btc'); + expect(chains).toContain('eth'); + }); + + it('accumulates fiat balance for same chain', () => { + const wallets = [makeWalletRow('eth', 40), makeWalletRow('eth', 60)]; + const account = makeAccountRow(wallets); + const result = buildAssetsByChain(account, 'USD'); + expect(result.length).toBe(1); + expect(result[0].fiatBalance).toBeCloseTo(100, 1); + expect(result[0].chainAssetsList.length).toBe(2); + }); + + it('returns one entry per distinct chain', () => { + const wallets = [makeWalletRow('btc', 10), makeWalletRow('eth', 200)]; + const account = makeAccountRow(wallets); + const result = buildAssetsByChain(account, 'USD'); + const chains = result.map(r => r.chain).sort(); + expect(chains).toEqual(['btc', 'eth']); + }); +}); + +// ─── buildAssetsByChainList ─────────────────────────────────────────────────── + +describe('buildAssetsByChainList', () => { + const makeWalletRow = (chain: string, fiatBalance = 0): any => ({ + id: `wallet-${chain}`, + chain, + chainName: chain.toUpperCase(), + img: '', + badgeImg: '', + currencyAbbreviation: chain, + fiatBalance, + fiatLockedBalance: 0, + fiatConfirmedLockedBalance: 0, + fiatSpendableBalance: 0, + fiatPendingBalance: 0, + receiveAddress: '0xaddr', + }); + + const makeAccountRow = (wallets: any[]): any => ({ + id: 'account-1', + keyId: 'key-1', + chains: wallets.map(w => w.chain), + wallets, + accountName: 'Test Account', + accountNumber: 0, + receiveAddress: '0xaddr', + isMultiNetworkSupported: true, + fiatBalance: 100, + fiatLockedBalance: 0, + fiatConfirmedLockedBalance: 0, + fiatSpendableBalance: 100, + fiatPendingBalance: 0, + fiatBalanceFormat: '$100.00', + fiatLockedBalanceFormat: '$0.00', + fiatConfirmedLockedBalanceFormat: '$0.00', + fiatSpendableBalanceFormat: '$100.00', + fiatPendingBalanceFormat: '$0.00', + }); + + it('returns empty array for account with no wallets', () => { + const account = makeAccountRow([]); + const result = buildAssetsByChainList(account, 'USD'); + expect(result).toEqual([]); + }); + + it('builds section list format grouped by chain', () => { + const wallets = [makeWalletRow('btc', 50), makeWalletRow('eth', 100)]; + const account = makeAccountRow(wallets); + const result = buildAssetsByChainList(account, 'USD'); + expect(result.length).toBe(2); + const titles = result.map(r => r.title); + expect(titles).toContain('btc'); + expect(titles).toContain('eth'); + }); + + it('accumulates balance for wallets on the same chain', () => { + const wallets = [makeWalletRow('eth', 30), makeWalletRow('eth', 70)]; + const account = makeAccountRow(wallets); + const result = buildAssetsByChainList(account, 'USD'); + expect(result.length).toBe(1); + expect(result[0].data![0].fiatBalance).toBeCloseTo(100, 1); + }); + + it('sorts by fiatBalance descending', () => { + const wallets = [makeWalletRow('btc', 10), makeWalletRow('eth', 200)]; + const account = makeAccountRow(wallets); + const result = buildAssetsByChainList(account, 'USD'); + expect(result[0].data![0].fiatBalance).toBeGreaterThan( + result[1].data![0].fiatBalance, + ); + }); +}); diff --git a/src/store/wallet/wallet.reducer.spec.ts b/src/store/wallet/wallet.reducer.spec.ts new file mode 100644 index 0000000000..ca130676e9 --- /dev/null +++ b/src/store/wallet/wallet.reducer.spec.ts @@ -0,0 +1,980 @@ +/** + * Tests for wallet.reducer.ts + * + * Each action handled by walletReducer is exercised as a pure function: + * walletReducer(state, action) → newState + * + * No Redux store or middleware is needed — reducers are pure functions. + */ + +import {walletReducer, initialState, WalletState} from './wallet.reducer'; +import {WalletActionTypes} from './wallet.types'; +import {FeeLevels} from './effects/fee/fee'; +import {Key, Wallet, CryptoBalance} from './wallet.models'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeBalance = ( + overrides: Partial = {}, +): CryptoBalance => ({ + crypto: '0', + cryptoLocked: '0', + cryptoConfirmedLocked: '0', + cryptoSpendable: '0', + cryptoPending: '0', + sat: 0, + satAvailable: 0, + satLocked: 0, + satConfirmedLocked: 0, + satConfirmed: 0, + satConfirmedAvailable: 0, + satSpendable: 0, + satPending: 0, + ...overrides, +}); + +/** Build a minimal Wallet stub — only the fields the reducer actually reads */ +const makeWallet = (overrides: Partial = {}): Wallet => + ({ + id: 'wallet-1', + keyId: 'key-1', + chain: 'btc', + chainName: 'Bitcoin', + currencyName: 'Bitcoin', + currencyAbbreviation: 'BTC', + m: 1, + n: 1, + balance: makeBalance() as any, + copayers: [], + pendingTxps: [], + img: '', + network: 'livenet', + hideWallet: false, + hideWalletByAccount: false, + receiveAddress: undefined, + isScanning: false, + transactionHistory: undefined, + ...overrides, + } as unknown as Wallet); + +/** Build a minimal Key stub */ +const makeKey = (overrides: Partial = {}): Key => ({ + id: 'key-1', + wallets: [], + properties: undefined, + methods: undefined, + backupComplete: false, + totalBalance: 0, + totalBalanceLastDay: 0, + isPrivKeyEncrypted: false, + hideKeyBalance: false, + isReadOnly: false, + ...overrides, +}); + +/** Return a fresh initialState clone so mutations don't bleed between tests */ +const freshState = (): WalletState => ({ + ...initialState, + portfolioBalance: {...initialState.portfolioBalance}, + keys: {}, + balanceCacheKey: {}, + feeLevel: {...initialState.feeLevel}, +}); + +/** State pre-seeded with one key containing one wallet */ +const stateWithKey = ( + keyOverrides: Partial = {}, + walletOverrides: Partial = {}, +): WalletState => { + const wallet = makeWallet(walletOverrides); + const key = makeKey({wallets: [wallet], ...keyOverrides}); + return { + ...freshState(), + keys: {'key-1': key}, + }; +}; + +// --------------------------------------------------------------------------- +// Default state +// --------------------------------------------------------------------------- + +describe('walletReducer — default state', () => { + it('returns initialState when called with undefined state and unknown action', () => { + const state = walletReducer(undefined, {type: '@@INIT'} as any); + expect(state.keys).toEqual({}); + expect(state.walletTermsAccepted).toBe(false); + expect(state.useUnconfirmedFunds).toBe(false); + expect(state.customizeNonce).toBe(false); + expect(state.queuedTransactions).toBe(false); + expect(state.enableReplaceByFee).toBe(false); + expect(state.customTokensMigrationComplete).toBe(false); + expect(state.polygonMigrationComplete).toBe(false); + expect(state.accountEvmCreationMigrationComplete).toBe(false); + expect(state.accountSvmCreationMigrationComplete).toBe(false); + expect(state.svmAddressFixComplete).toBe(false); + expect(state.pendingJoinerSession).toBeNull(); + expect(state.tssEnabled).toBe(false); + }); + + it('has the correct initial feeLevel defaults', () => { + const state = walletReducer(undefined, {type: '@@INIT'} as any); + expect(state.feeLevel.btc).toBe(FeeLevels.NORMAL); + expect(state.feeLevel.eth).toBe(FeeLevels.PRIORITY); + expect(state.feeLevel.matic).toBe(FeeLevels.NORMAL); + expect(state.feeLevel.sol).toBe(FeeLevels.NORMAL); + }); + + it('has zeroed portfolio balance by default', () => { + const state = walletReducer(undefined, {type: '@@INIT'} as any); + expect(state.portfolioBalance).toEqual({ + current: 0, + lastDay: 0, + previous: 0, + }); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_CREATE_KEY +// --------------------------------------------------------------------------- + +describe('SUCCESS_CREATE_KEY', () => { + it('adds a new key to the keys map', () => { + const key = makeKey({id: 'key-abc'}); + const state = walletReducer(freshState(), { + type: WalletActionTypes.SUCCESS_CREATE_KEY, + payload: {key}, + }); + expect(state.keys['key-abc']).toEqual(key); + }); + + it('preserves existing keys when adding a new one', () => { + const existing = makeKey({id: 'key-existing'}); + const base: WalletState = { + ...freshState(), + keys: {'key-existing': existing}, + }; + const newKey = makeKey({id: 'key-new'}); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_CREATE_KEY, + payload: {key: newKey}, + }); + expect(Object.keys(state.keys)).toHaveLength(2); + expect(state.keys['key-existing']).toEqual(existing); + expect(state.keys['key-new']).toEqual(newKey); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_UPDATE_KEY / SUCCESS_ADD_WALLET / SUCCESS_IMPORT +// --------------------------------------------------------------------------- + +describe('SUCCESS_UPDATE_KEY / SUCCESS_ADD_WALLET / SUCCESS_IMPORT', () => { + const actionTypes = [ + WalletActionTypes.SUCCESS_UPDATE_KEY, + WalletActionTypes.SUCCESS_ADD_WALLET, + WalletActionTypes.SUCCESS_IMPORT, + ] as const; + + actionTypes.forEach(type => { + it(`[${type}] upserts key into the keys map`, () => { + const key = makeKey({id: 'key-1', keyName: 'Updated'}); + const state = walletReducer(freshState(), {type, payload: {key}} as any); + expect(state.keys['key-1'].keyName).toBe('Updated'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// SET_BACKUP_COMPLETE +// --------------------------------------------------------------------------- + +describe('SET_BACKUP_COMPLETE', () => { + it('marks backupComplete=true on the targeted key', () => { + const base = stateWithKey({backupComplete: false}); + const state = walletReducer(base, { + type: WalletActionTypes.SET_BACKUP_COMPLETE, + payload: 'key-1', + }); + expect(state.keys['key-1'].backupComplete).toBe(true); + }); + + it('returns unchanged state when keyId does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.SET_BACKUP_COMPLETE, + payload: 'nonexistent-key', + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// DELETE_KEY +// --------------------------------------------------------------------------- + +describe('DELETE_KEY', () => { + it('removes the key from the keys map', () => { + const base = stateWithKey({id: 'key-1', totalBalance: 100}); + const state = walletReducer(base, { + type: WalletActionTypes.DELETE_KEY, + payload: {keyId: 'key-1'}, + }); + expect(state.keys['key-1']).toBeUndefined(); + }); + + it('subtracts the deleted key totalBalance from the portfolio balance', () => { + const key = makeKey({id: 'key-1', totalBalance: 500}); + const base: WalletState = { + ...freshState(), + keys: {'key-1': key}, + portfolioBalance: {current: 1000, lastDay: 800, previous: 0}, + }; + const state = walletReducer(base, { + type: WalletActionTypes.DELETE_KEY, + payload: {keyId: 'key-1'}, + }); + expect(state.portfolioBalance.current).toBe(500); + expect(state.portfolioBalance.lastDay).toBe(300); + expect(state.portfolioBalance.previous).toBe(0); + }); + + it('returns unchanged state when keyId does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.DELETE_KEY, + payload: {keyId: 'ghost'}, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// SET_WALLET_TERMS_ACCEPTED +// --------------------------------------------------------------------------- + +describe('SET_WALLET_TERMS_ACCEPTED', () => { + it('sets walletTermsAccepted to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_WALLET_TERMS_ACCEPTED, + }); + expect(state.walletTermsAccepted).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_PORTFOLIO_BALANCE +// --------------------------------------------------------------------------- + +describe('UPDATE_PORTFOLIO_BALANCE', () => { + it('sums totalBalance across all keys for current', () => { + const key1 = makeKey({ + id: 'k1', + totalBalance: 200, + totalBalanceLastDay: 150, + }); + const key2 = makeKey({ + id: 'k2', + totalBalance: 300, + totalBalanceLastDay: 250, + }); + const base: WalletState = {...freshState(), keys: {k1: key1, k2: key2}}; + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_PORTFOLIO_BALANCE, + }); + expect(state.portfolioBalance.current).toBe(500); + expect(state.portfolioBalance.lastDay).toBe(400); + expect(state.portfolioBalance.previous).toBe(0); + }); + + it('computes 0 when there are no keys', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.UPDATE_PORTFOLIO_BALANCE, + }); + expect(state.portfolioBalance.current).toBe(0); + expect(state.portfolioBalance.lastDay).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_UPDATE_KEYS_TOTAL_BALANCE +// --------------------------------------------------------------------------- + +describe('SUCCESS_UPDATE_KEYS_TOTAL_BALANCE', () => { + it('updates totalBalance and totalBalanceLastDay on existing keys', () => { + const base = stateWithKey(); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_UPDATE_KEYS_TOTAL_BALANCE, + payload: [ + {keyId: 'key-1', totalBalance: 9999, totalBalanceLastDay: 8888}, + ], + }); + expect(state.keys['key-1'].totalBalance).toBe(9999); + expect(state.keys['key-1'].totalBalanceLastDay).toBe(8888); + }); + + it('sets a balanceCacheKey entry for the updated key', () => { + const base = stateWithKey(); + const before = Date.now(); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_UPDATE_KEYS_TOTAL_BALANCE, + payload: [{keyId: 'key-1', totalBalance: 1, totalBalanceLastDay: 1}], + }); + expect(state.balanceCacheKey['key-1']).toBeGreaterThanOrEqual(before); + }); + + it('ignores entries for keys that do not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_UPDATE_KEYS_TOTAL_BALANCE, + payload: [{keyId: 'ghost', totalBalance: 100, totalBalanceLastDay: 100}], + }); + expect(state.keys['ghost']).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_UPDATE_ALL_KEYS_AND_STATUS +// --------------------------------------------------------------------------- + +describe('SUCCESS_UPDATE_ALL_KEYS_AND_STATUS', () => { + it('stamps balanceCacheKey.all with a timestamp', () => { + const before = Date.now(); + const state = walletReducer(freshState(), { + type: WalletActionTypes.SUCCESS_UPDATE_ALL_KEYS_AND_STATUS, + }); + expect(state.balanceCacheKey.all).toBeGreaterThanOrEqual(before); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_UPDATE_WALLET_STATUS +// --------------------------------------------------------------------------- + +describe('SUCCESS_UPDATE_WALLET_STATUS', () => { + it('updates balance, pendingTxps, singleAddress on the matching wallet', () => { + const base = stateWithKey({}, {id: 'wallet-1'}); + const newBalance = makeBalance({sat: 12345}); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_UPDATE_WALLET_STATUS, + payload: { + keyId: 'key-1', + walletId: 'wallet-1', + status: {balance: newBalance, pendingTxps: [], singleAddress: true}, + }, + }); + expect((state.keys['key-1'].wallets[0] as any).balance).toEqual(newBalance); + expect((state.keys['key-1'].wallets[0] as any).singleAddress).toBe(true); + }); + + it('sets a balanceCacheKey entry for the wallet', () => { + const base = stateWithKey(); + const before = Date.now(); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_UPDATE_WALLET_STATUS, + payload: { + keyId: 'key-1', + walletId: 'wallet-1', + status: {balance: makeBalance(), pendingTxps: [], singleAddress: false}, + }, + }); + expect(state.balanceCacheKey['wallet-1']).toBeGreaterThanOrEqual(before); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_UPDATE_WALLET_STATUS, + payload: { + keyId: 'missing', + walletId: 'wallet-1', + status: {balance: makeBalance(), pendingTxps: [], singleAddress: false}, + }, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_GET_CUSTOM_TOKEN_OPTIONS +// --------------------------------------------------------------------------- + +describe('SUCCESS_GET_CUSTOM_TOKEN_OPTIONS', () => { + it('merges new custom token options into existing ones', () => { + const base: WalletState = { + ...freshState(), + customTokenOptionsByAddress: {'0xold': {symbol: 'OLD'} as any}, + }; + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_GET_CUSTOM_TOKEN_OPTIONS, + payload: { + customTokenOptionsByAddress: {'0xnew': {symbol: 'NEW'} as any}, + customTokenDataByAddress: {'0xnew': {coin: 'eth'} as any}, + }, + }); + expect(state.customTokenOptionsByAddress['0xold']).toBeDefined(); + expect(state.customTokenOptionsByAddress['0xnew']).toBeDefined(); + expect(state.customTokenDataByAddress['0xnew']).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_GET_RECEIVE_ADDRESS +// --------------------------------------------------------------------------- + +describe('SUCCESS_GET_RECEIVE_ADDRESS', () => { + it('sets the receiveAddress on the matching wallet', () => { + const base = stateWithKey({}, {id: 'wallet-1'}); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_GET_RECEIVE_ADDRESS, + payload: { + keyId: 'key-1', + walletId: 'wallet-1', + receiveAddress: '1BpEi6DfDAUFd153wiGrvkiZWhavi', + }, + }); + expect((state.keys['key-1'].wallets[0] as any).receiveAddress).toBe( + '1BpEi6DfDAUFd153wiGrvkiZWhavi', + ); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_GET_RECEIVE_ADDRESS, + payload: {keyId: 'nope', walletId: 'wallet-1', receiveAddress: 'addr'}, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_KEY_NAME +// --------------------------------------------------------------------------- + +describe('UPDATE_KEY_NAME', () => { + it('updates the keyName on the specified key', () => { + const base = stateWithKey(); + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_KEY_NAME, + payload: {keyId: 'key-1', name: 'My BTC Key'}, + }); + expect(state.keys['key-1'].keyName).toBe('My BTC Key'); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_KEY_NAME, + payload: {keyId: 'ghost', name: 'Ghost'}, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_WALLET_NAME +// --------------------------------------------------------------------------- + +describe('UPDATE_WALLET_NAME', () => { + it('updates walletName on the matching wallet', () => { + const base = stateWithKey({}, {id: 'wallet-1'}); + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_WALLET_NAME, + payload: {keyId: 'key-1', walletId: 'wallet-1', name: 'Savings'}, + }); + expect((state.keys['key-1'].wallets[0] as any).walletName).toBe('Savings'); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_WALLET_NAME, + payload: {keyId: 'ghost', walletId: 'wallet-1', name: 'Name'}, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_ACCOUNT_NAME +// --------------------------------------------------------------------------- + +describe('UPDATE_ACCOUNT_NAME', () => { + it('sets evmAccountsInfo name for the given address', () => { + const base = stateWithKey(); + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_ACCOUNT_NAME, + payload: {keyId: 'key-1', accountAddress: '0xabc', name: 'Trading'}, + }); + expect(state.keys['key-1'].evmAccountsInfo?.['0xabc']?.name).toBe( + 'Trading', + ); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_ACCOUNT_NAME, + payload: {keyId: 'ghost', accountAddress: '0x1', name: 'N'}, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// SET_WALLET_SCANNING +// --------------------------------------------------------------------------- + +describe('SET_WALLET_SCANNING', () => { + it('sets isScanning=true on the target wallet', () => { + const base = stateWithKey({}, {id: 'wallet-1', isScanning: false}); + const state = walletReducer(base, { + type: WalletActionTypes.SET_WALLET_SCANNING, + payload: {keyId: 'key-1', walletId: 'wallet-1', isScanning: true}, + }); + expect((state.keys['key-1'].wallets[0] as any).isScanning).toBe(true); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.SET_WALLET_SCANNING, + payload: {keyId: 'nope', walletId: 'wallet-1', isScanning: true}, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_WALLET_TX_HISTORY +// --------------------------------------------------------------------------- + +describe('UPDATE_WALLET_TX_HISTORY', () => { + it('sets transactionHistory on the matching wallet', () => { + const base = stateWithKey({}, {id: 'wallet-1'}); + const history = { + transactions: [{id: 'tx1'}], + loadMore: false, + hasConfirmingTxs: false, + }; + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_WALLET_TX_HISTORY, + payload: { + keyId: 'key-1', + walletId: 'wallet-1', + transactionHistory: history, + }, + }); + expect((state.keys['key-1'].wallets[0] as any).transactionHistory).toEqual( + history, + ); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_WALLET_TX_HISTORY, + payload: { + keyId: 'ghost', + walletId: 'wallet-1', + transactionHistory: { + transactions: [], + loadMore: false, + hasConfirmingTxs: false, + }, + }, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_ACCOUNT_TX_HISTORY +// --------------------------------------------------------------------------- + +describe('UPDATE_ACCOUNT_TX_HISTORY', () => { + it('applies transactionHistory to matching wallets by id', () => { + const base = stateWithKey({}, {id: 'wallet-1'}); + const history = { + transactions: [{id: 'tx2'}], + loadMore: true, + hasConfirmingTxs: true, + }; + const state = walletReducer(base, { + type: WalletActionTypes.UPDATE_ACCOUNT_TX_HISTORY, + payload: { + keyId: 'key-1', + accountTransactionsHistory: {'wallet-1': history}, + }, + }); + expect((state.keys['key-1'].wallets[0] as any).transactionHistory).toEqual( + history, + ); + }); +}); + +// --------------------------------------------------------------------------- +// SET_USE_UNCONFIRMED_FUNDS +// --------------------------------------------------------------------------- + +describe('SET_USE_UNCONFIRMED_FUNDS', () => { + it('sets useUnconfirmedFunds to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_USE_UNCONFIRMED_FUNDS, + payload: true, + }); + expect(state.useUnconfirmedFunds).toBe(true); + }); + + it('sets useUnconfirmedFunds back to false', () => { + const base: WalletState = {...freshState(), useUnconfirmedFunds: true}; + const state = walletReducer(base, { + type: WalletActionTypes.SET_USE_UNCONFIRMED_FUNDS, + payload: false, + }); + expect(state.useUnconfirmedFunds).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// SET_CUSTOMIZE_NONCE +// --------------------------------------------------------------------------- + +describe('SET_CUSTOMIZE_NONCE', () => { + it('toggles customizeNonce', () => { + const s1 = walletReducer(freshState(), { + type: WalletActionTypes.SET_CUSTOMIZE_NONCE, + payload: true, + }); + expect(s1.customizeNonce).toBe(true); + const s2 = walletReducer(s1, { + type: WalletActionTypes.SET_CUSTOMIZE_NONCE, + payload: false, + }); + expect(s2.customizeNonce).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// SET_QUEUED_TRANSACTIONS +// --------------------------------------------------------------------------- + +describe('SET_QUEUED_TRANSACTIONS', () => { + it('sets queuedTransactions to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_QUEUED_TRANSACTIONS, + payload: true, + }); + expect(state.queuedTransactions).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// SET_ENABLE_REPLACE_BY_FEE +// --------------------------------------------------------------------------- + +describe('SET_ENABLE_REPLACE_BY_FEE', () => { + it('sets enableReplaceByFee to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_ENABLE_REPLACE_BY_FEE, + payload: true, + }); + expect(state.enableReplaceByFee).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// SYNC_WALLETS +// --------------------------------------------------------------------------- + +describe('SYNC_WALLETS', () => { + it('concatenates new wallets to the existing wallet list', () => { + const base = stateWithKey({}, {id: 'wallet-1'}); + const newWallet = makeWallet({id: 'wallet-2'}); + const state = walletReducer(base, { + type: WalletActionTypes.SYNC_WALLETS, + payload: {keyId: 'key-1', wallets: [newWallet]}, + }); + expect(state.keys['key-1'].wallets).toHaveLength(2); + expect(state.keys['key-1'].wallets[1].id).toBe('wallet-2'); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.SYNC_WALLETS, + payload: {keyId: 'ghost', wallets: [makeWallet()]}, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// TOGGLE_HIDE_WALLET +// --------------------------------------------------------------------------- + +describe('TOGGLE_HIDE_WALLET', () => { + it('toggles hideWallet from false to true', () => { + const base = stateWithKey({}, {id: 'wallet-1', hideWallet: false}); + const wallet = base.keys['key-1'].wallets[0]; + const state = walletReducer(base, { + type: WalletActionTypes.TOGGLE_HIDE_WALLET, + payload: {wallet}, + }); + expect((state.keys['key-1'].wallets[0] as any).hideWallet).toBe(true); + }); + + it('toggles hideWallet from true to false', () => { + const base = stateWithKey({}, {id: 'wallet-1', hideWallet: true}); + const wallet = base.keys['key-1'].wallets[0]; + const state = walletReducer(base, { + type: WalletActionTypes.TOGGLE_HIDE_WALLET, + payload: {wallet}, + }); + expect((state.keys['key-1'].wallets[0] as any).hideWallet).toBe(false); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const wallet = makeWallet({keyId: 'ghost'}); + const state = walletReducer(base, { + type: WalletActionTypes.TOGGLE_HIDE_WALLET, + payload: {wallet}, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// TOGGLE_HIDE_ACCOUNT +// --------------------------------------------------------------------------- + +describe('TOGGLE_HIDE_ACCOUNT', () => { + it('sets hideAccount=true for a new account address', () => { + const base = stateWithKey({}, {receiveAddress: '0xabc'}); + const state = walletReducer(base, { + type: WalletActionTypes.TOGGLE_HIDE_ACCOUNT, + payload: { + keyId: 'key-1', + accountAddress: '0xabc', + accountToggleSelected: true, + }, + }); + expect(state.keys['key-1'].evmAccountsInfo?.['0xabc']?.hideAccount).toBe( + true, + ); + }); + + it('also sets hideWalletByAccount on wallets with matching receiveAddress', () => { + const base = stateWithKey( + {}, + {id: 'wallet-1', receiveAddress: '0xabc', hideWalletByAccount: false}, + ); + const state = walletReducer(base, { + type: WalletActionTypes.TOGGLE_HIDE_ACCOUNT, + payload: {keyId: 'key-1', accountAddress: '0xabc'}, + }); + expect((state.keys['key-1'].wallets[0] as any).hideWalletByAccount).toBe( + true, + ); + }); + + it('returns unchanged state when key does not exist', () => { + const base = freshState(); + const state = walletReducer(base, { + type: WalletActionTypes.TOGGLE_HIDE_ACCOUNT, + payload: {keyId: 'ghost', accountAddress: '0x1'}, + }); + expect(state).toBe(base); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE_CACHE_FEE_LEVEL +// --------------------------------------------------------------------------- + +describe('UPDATE_CACHE_FEE_LEVEL', () => { + it('updates the fee level for the given currency', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.UPDATE_CACHE_FEE_LEVEL, + payload: {currency: 'btc', feeLevel: FeeLevels.URGENT}, + }); + expect(state.feeLevel.btc).toBe(FeeLevels.URGENT); + }); + + it('does not affect fee levels for other currencies', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.UPDATE_CACHE_FEE_LEVEL, + payload: {currency: 'btc', feeLevel: FeeLevels.ECONOMY}, + }); + expect(state.feeLevel.eth).toBe(FeeLevels.PRIORITY); + }); +}); + +// --------------------------------------------------------------------------- +// Migration flags +// --------------------------------------------------------------------------- + +describe('migration flags', () => { + it('SET_CUSTOM_TOKENS_MIGRATION_COMPLETE sets flag to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_CUSTOM_TOKENS_MIGRATION_COMPLETE, + }); + expect(state.customTokensMigrationComplete).toBe(true); + }); + + it('SET_POLYGON_MIGRATION_COMPLETE sets flag to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_POLYGON_MIGRATION_COMPLETE, + }); + expect(state.polygonMigrationComplete).toBe(true); + }); + + it('SET_ACCOUNT_EVM_CREATION_MIGRATION_COMPLETE sets flag to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_ACCOUNT_EVM_CREATION_MIGRATION_COMPLETE, + }); + expect(state.accountEvmCreationMigrationComplete).toBe(true); + }); + + it('SET_ACCOUNT_SVM_CREATION_MIGRATION_COMPLETE sets flag to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_ACCOUNT_SVM_CREATION_MIGRATION_COMPLETE, + }); + expect(state.accountSvmCreationMigrationComplete).toBe(true); + }); + + it('SET_SVM_ADDRESS_CREATION_FIX_COMPLETE sets svmAddressFixComplete to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_SVM_ADDRESS_CREATION_FIX_COMPLETE, + }); + expect(state.svmAddressFixComplete).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// SUCCESS_UPDATE_WALLET_BALANCES_AND_STATUS +// --------------------------------------------------------------------------- + +describe('SUCCESS_UPDATE_WALLET_BALANCES_AND_STATUS', () => { + it('updates key totalBalance and totalBalanceLastDay', () => { + const base = stateWithKey({totalBalance: 0, totalBalanceLastDay: 0}); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_UPDATE_WALLET_BALANCES_AND_STATUS, + payload: { + keyBalances: [ + {keyId: 'key-1', totalBalance: 777, totalBalanceLastDay: 666}, + ], + walletBalances: [], + }, + }); + expect(state.keys['key-1'].totalBalance).toBe(777); + expect(state.keys['key-1'].totalBalanceLastDay).toBe(666); + }); + + it('updates wallet balance and status', () => { + const base = stateWithKey({}, {id: 'wallet-1'}); + const newBalance = makeBalance({sat: 9999}); + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_UPDATE_WALLET_BALANCES_AND_STATUS, + payload: { + keyBalances: [], + walletBalances: [ + { + keyId: 'key-1', + walletId: 'wallet-1', + status: {balance: newBalance, pendingTxps: [], singleAddress: true}, + }, + ], + }, + }); + expect((state.keys['key-1'].wallets[0] as any).balance).toEqual(newBalance); + expect((state.keys['key-1'].wallets[0] as any).singleAddress).toBe(true); + }); + + it('recalculates portfolio balance based on updated key totals', () => { + const key1 = makeKey({ + id: 'k1', + totalBalance: 100, + totalBalanceLastDay: 80, + }); + const key2 = makeKey({ + id: 'k2', + totalBalance: 200, + totalBalanceLastDay: 160, + }); + const base: WalletState = { + ...freshState(), + keys: {k1: key1, k2: key2}, + portfolioBalance: {current: 100, lastDay: 80, previous: 0}, + }; + const state = walletReducer(base, { + type: WalletActionTypes.SUCCESS_UPDATE_WALLET_BALANCES_AND_STATUS, + payload: { + keyBalances: [ + {keyId: 'k1', totalBalance: 150, totalBalanceLastDay: 120}, + {keyId: 'k2', totalBalance: 250, totalBalanceLastDay: 200}, + ], + walletBalances: [], + }, + }); + expect(state.portfolioBalance.current).toBe(400); + expect(state.portfolioBalance.lastDay).toBe(320); + // previous should be the prior current + expect(state.portfolioBalance.previous).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// SET_PENDING_JOINER_SESSION / REMOVE_PENDING_JOINER_SESSION +// --------------------------------------------------------------------------- + +describe('SET_PENDING_JOINER_SESSION', () => { + it('stores the session in pendingJoinerSession', () => { + const session = {walletId: 'w1', secret: 'abc'} as any; + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_PENDING_JOINER_SESSION, + payload: session, + }); + expect(state.pendingJoinerSession).toEqual(session); + }); +}); + +describe('REMOVE_PENDING_JOINER_SESSION', () => { + it('resets pendingJoinerSession to null', () => { + const base: WalletState = { + ...freshState(), + pendingJoinerSession: {walletId: 'w1', secret: 'abc'} as any, + }; + const state = walletReducer(base, { + type: WalletActionTypes.REMOVE_PENDING_JOINER_SESSION, + } as any); + expect(state.pendingJoinerSession).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// SET_TSS_ENABLED +// --------------------------------------------------------------------------- + +describe('SET_TSS_ENABLED', () => { + it('sets tssEnabled to true', () => { + const state = walletReducer(freshState(), { + type: WalletActionTypes.SET_TSS_ENABLED, + payload: true, + }); + expect(state.tssEnabled).toBe(true); + }); + + it('sets tssEnabled back to false', () => { + const base: WalletState = {...freshState(), tssEnabled: true}; + const state = walletReducer(base, { + type: WalletActionTypes.SET_TSS_ENABLED, + payload: false, + }); + expect(state.tssEnabled).toBe(false); + }); +}); diff --git a/src/utils/color.spec.ts b/src/utils/color.spec.ts new file mode 100644 index 0000000000..2bfbcf7adc --- /dev/null +++ b/src/utils/color.spec.ts @@ -0,0 +1,51 @@ +import {hexToRGB, getBrightness, isDark} from './color'; + +describe('hexToRGB', () => { + it('converts a 6-digit hex to rgb', () => { + expect(hexToRGB('#ffffff')).toBe('rgb(255,255,255)'); + expect(hexToRGB('#000000')).toBe('rgb(0,0,0)'); + expect(hexToRGB('#ff0000')).toBe('rgb(255,0,0)'); + expect(hexToRGB('#1a3b8b')).toBe('rgb(26,59,139)'); + }); + + it('converts a 3-digit hex to rgb', () => { + expect(hexToRGB('#fff')).toBe('rgb(255,255,255)'); + expect(hexToRGB('#000')).toBe('rgb(0,0,0)'); + expect(hexToRGB('#f00')).toBe('rgb(255,0,0)'); + }); +}); + +describe('getBrightness', () => { + it('returns 255 for white', () => { + expect(getBrightness('#ffffff')).toBeCloseTo(255, 0); + }); + + it('returns 0 for black', () => { + expect(getBrightness('#000000')).toBe(0); + }); + + it('accepts rgb strings directly', () => { + expect(getBrightness('rgb(255,255,255)')).toBeCloseTo(255, 0); + expect(getBrightness('rgb(0,0,0)')).toBe(0); + }); + + it('returns a value between 0 and 255', () => { + const brightness = getBrightness('#1a3b8b'); + expect(brightness).toBeGreaterThanOrEqual(0); + expect(brightness).toBeLessThanOrEqual(255); + }); +}); + +describe('isDark', () => { + it('returns true for dark colors', () => { + expect(isDark('#000000')).toBe(true); + expect(isDark('#1a3b8b')).toBe(true); // BitPay dark blue + expect(isDark('#333333')).toBe(true); + }); + + it('returns false for light colors', () => { + expect(isDark('#ffffff')).toBe(false); + expect(isDark('#ffff00')).toBe(false); + expect(isDark('#cccccc')).toBe(false); + }); +}); diff --git a/src/utils/helper-methods.spec.ts b/src/utils/helper-methods.spec.ts new file mode 100644 index 0000000000..0cb4480f7d --- /dev/null +++ b/src/utils/helper-methods.spec.ts @@ -0,0 +1,1384 @@ +// Mock heavy wallet/crypto dependencies that are imported at module level +// but not needed for the pure utility functions under test. +jest.mock('../store/wallet/effects/address/address', () => ({ + createWalletAddress: jest.fn(), +})); +jest.mock('../store/wallet/effects', () => ({ + createMultipleWallets: jest.fn(), +})); +jest.mock('../store/wallet/utils/wallet', () => ({ + checkEncryptPassword: jest.fn(), + toFiat: jest.fn(), +})); +jest.mock('../store/wallet/effects/amount/amount', () => ({ + FormatAmount: jest.fn(), +})); +jest.mock('../store/moralis/moralis.effects', () => ({ + getERC20TokenPrice: jest.fn(), +})); +jest.mock('../api/etherscan', () => ({default: {}})); +jest.mock('../managers/TokenManager', () => ({tokenManager: {}})); +jest.mock('../managers/LogManager', () => ({ + logManager: { + log: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { + titleCasing, + unitStringToAtomicBigInt, + atomicToUnitString, + changeOpacity, + parsePath, + getDerivationStrategy, + getNetworkName, + getAccount, + isValidDerivationPath, + keyExtractor, + getSignificantDigits, + formatFiatAmount, + formatFiat, + findContact, + getContactObj, + shouldScale, + formatCryptoAddress, + calculatePercentageDifference, + getLastDayTimestampStartOfHourMs, + formatFiatAmountObj, + convertToFiat, + getErrorString, + addTokenChainSuffix, + formatCurrencyAbbreviation, + getCurrencyAbbreviation, + getProtocolName, + getProtocolsName, + getEVMFeeCurrency, + getCWCChain, + getChainUsingSuffix, + isL2NoSideChainNetwork, + camelCaseToUpperWords, + removeTrailingZeros, + extractAddresses, + splitInputsToChunks, + toggleTSSModal, + sleep, + suffixChainMap, + transformAmount, + getRateByCurrencyName, + getBadgeImg, + getChainFromTokenByAddressKey, + getFullLinkedWallet, + getMnemonic, + getVMGasWallets, + getEvmGasWallets, + getSvmGasWallets, +} from './helper-methods'; +import {Network} from '../constants'; + +describe('titleCasing', () => { + it('capitalizes the first letter', () => { + expect(titleCasing('hello')).toBe('Hello'); + expect(titleCasing('world')).toBe('World'); + }); + + it('leaves already-capitalized strings unchanged', () => { + expect(titleCasing('Hello')).toBe('Hello'); + }); + + it('handles single character', () => { + expect(titleCasing('a')).toBe('A'); + }); +}); + +describe('unitStringToAtomicBigInt', () => { + it('converts whole number strings', () => { + expect(unitStringToAtomicBigInt('1', 8)).toBe(100000000n); + expect(unitStringToAtomicBigInt('10', 8)).toBe(1000000000n); + expect(unitStringToAtomicBigInt('0', 8)).toBe(0n); + }); + + it('converts decimal strings', () => { + expect(unitStringToAtomicBigInt('0.5', 8)).toBe(50000000n); + expect(unitStringToAtomicBigInt('1.5', 8)).toBe(150000000n); + expect(unitStringToAtomicBigInt('0.00000001', 8)).toBe(1n); + }); + + it('handles negative values', () => { + expect(unitStringToAtomicBigInt('-1', 8)).toBe(-100000000n); + expect(unitStringToAtomicBigInt('-0.5', 8)).toBe(-50000000n); + }); + + it('returns 0n for empty/invalid input', () => { + expect(unitStringToAtomicBigInt('', 8)).toBe(0n); + expect(unitStringToAtomicBigInt(undefined as any, 8)).toBe(0n); + }); + + it('strips commas from formatted numbers', () => { + expect(unitStringToAtomicBigInt('1,000', 8)).toBe(100000000000n); + }); + + it('handles 0 decimals', () => { + expect(unitStringToAtomicBigInt('42', 0)).toBe(42n); + }); + + it('truncates fractional digits beyond unitDecimals', () => { + // 0.123456789 with 6 decimals → 123456 + expect(unitStringToAtomicBigInt('0.123456789', 6)).toBe(123456n); + }); +}); + +describe('atomicToUnitString', () => { + it('converts atomic bigint back to unit string', () => { + expect(atomicToUnitString(100000000n, 8)).toBe('1'); + expect(atomicToUnitString(150000000n, 8)).toBe('1.5'); + expect(atomicToUnitString(1n, 8)).toBe('0.00000001'); + }); + + it('handles zero', () => { + expect(atomicToUnitString(0n, 8)).toBe('0'); + }); + + it('handles negative values', () => { + expect(atomicToUnitString(-100000000n, 8)).toBe('-1'); + expect(atomicToUnitString(-50000000n, 8)).toBe('-0.5'); + }); + + it('handles 0 decimals', () => { + expect(atomicToUnitString(42n, 0)).toBe('42'); + }); + + it('trims trailing zeros from fractional part', () => { + expect(atomicToUnitString(150000000n, 8)).toBe('1.5'); // not '1.50000000' + }); + + it('round-trips with unitStringToAtomicBigInt', () => { + const values = ['1', '0.5', '0.00000001', '100.12345678']; + for (const v of values) { + const atomic = unitStringToAtomicBigInt(v, 8); + expect(atomicToUnitString(atomic, 8)).toBe(v); + } + }); +}); + +describe('changeOpacity', () => { + it('converts a 6-digit hex to rgba with opacity', () => { + expect(changeOpacity('#ffffff', 0.5)).toBe('rgba(255, 255, 255, 0.5)'); + expect(changeOpacity('#000000', 1)).toBe('rgba(0, 0, 0, 1)'); + expect(changeOpacity('#1a3b8b', 0.8)).toBe('rgba(26, 59, 139, 0.8)'); + }); + + it('converts a 3-digit hex to rgba', () => { + expect(changeOpacity('#fff', 0.5)).toBe('rgba(255, 255, 255, 0.5)'); + expect(changeOpacity('#000', 1)).toBe('rgba(0, 0, 0, 1)'); + }); + + it('clamps opacity to 0–1', () => { + expect(changeOpacity('#ffffff', 2)).toBe('rgba(255, 255, 255, 1)'); + expect(changeOpacity('#ffffff', -1)).toBe('rgba(255, 255, 255, 0)'); + }); + + it('returns the original value for invalid hex', () => { + expect(changeOpacity('notacolor', 0.5)).toBe('notacolor'); + }); +}); + +describe('parsePath', () => { + it('parses BIP44 path', () => { + const result = parsePath("m/44'/0'/0'"); + expect(result.purpose).toBe("44'"); + expect(result.coinCode).toBe("0'"); + expect(result.account).toBe("0'"); + }); + + it('parses BIP84 path', () => { + const result = parsePath("m/84'/0'/0'"); + expect(result.purpose).toBe("84'"); + expect(result.coinCode).toBe("0'"); + }); +}); + +describe('getDerivationStrategy', () => { + it('returns BIP44 for purpose 44', () => { + expect(getDerivationStrategy("m/44'/0'/0'")).toBe('BIP44'); + }); + + it('returns BIP45 for purpose 45', () => { + expect(getDerivationStrategy("m/45'")).toBe('BIP45'); + }); + + it('returns BIP48 for purpose 48', () => { + expect(getDerivationStrategy("m/48'/0'/0'")).toBe('BIP48'); + }); + + it('returns BIP84 for purpose 84', () => { + expect(getDerivationStrategy("m/84'/0'/0'")).toBe('BIP84'); + }); + + it('returns empty string for unknown purpose', () => { + expect(getDerivationStrategy("m/99'/0'/0'")).toBe(''); + }); +}); + +describe('getNetworkName', () => { + it('returns livenet for BIP45', () => { + expect(getNetworkName("m/45'")).toBe('livenet'); + }); + + it('returns livenet for BTC mainnet coin code 0', () => { + expect(getNetworkName("m/44'/0'/0'")).toBe('livenet'); + }); + + it('returns testnet for coin code 1', () => { + expect(getNetworkName("m/44'/1'/0'")).toBe('testnet'); + }); + + it('returns livenet for ETH coin code 60', () => { + expect(getNetworkName("m/44'/60'/0'")).toBe('livenet'); + }); + + it('returns livenet for BCH coin code 145', () => { + expect(getNetworkName("m/44'/145'/0'")).toBe('livenet'); + }); +}); + +describe('getAccount', () => { + it('returns 0 for BIP45 paths', () => { + expect(getAccount("m/45'")).toBe(0); + }); + + it('parses account index from standard paths', () => { + expect(getAccount("m/44'/0'/0'")).toBe(0); + expect(getAccount("m/44'/0'/1'")).toBe(1); + expect(getAccount("m/44'/0'/5'")).toBe(5); + }); + + it('returns undefined when account segment is missing', () => { + expect(getAccount("m/44'/0'")).toBeUndefined(); + }); +}); + +// ─── NEW TESTS ──────────────────────────────────────────────────────────────── + +describe('isValidDerivationPath', () => { + it('returns true for BIP45 path regardless of chain', () => { + expect(isValidDerivationPath("m/45'", 'btc')).toBe(true); + expect(isValidDerivationPath("m/45'", 'eth')).toBe(true); + }); + + it('validates btc coin codes', () => { + expect(isValidDerivationPath("m/44'/0'/0'", 'btc')).toBe(true); + expect(isValidDerivationPath("m/44'/1'/0'", 'btc')).toBe(true); + expect(isValidDerivationPath("m/44'/145'/0'", 'btc')).toBe(false); + }); + + it('validates bch coin codes', () => { + expect(isValidDerivationPath("m/44'/145'/0'", 'bch')).toBe(true); + expect(isValidDerivationPath("m/44'/0'/0'", 'bch')).toBe(true); + expect(isValidDerivationPath("m/44'/1'/0'", 'bch')).toBe(true); + expect(isValidDerivationPath("m/44'/3'/0'", 'bch')).toBe(false); + }); + + it('validates eth/matic/arb/base/op coin codes', () => { + for (const chain of ['eth', 'matic', 'arb', 'base', 'op']) { + expect(isValidDerivationPath("m/44'/60'/0'", chain)).toBe(true); + expect(isValidDerivationPath("m/44'/0'/0'", chain)).toBe(true); + expect(isValidDerivationPath("m/44'/1'/0'", chain)).toBe(true); + expect(isValidDerivationPath("m/44'/145'/0'", chain)).toBe(false); + } + }); + + it('validates sol coin codes', () => { + expect(isValidDerivationPath("m/44'/501'/0'", 'sol')).toBe(true); + expect(isValidDerivationPath("m/44'/0'/0'", 'sol')).toBe(true); + expect(isValidDerivationPath("m/44'/1'/0'", 'sol')).toBe(true); + expect(isValidDerivationPath("m/44'/60'/0'", 'sol')).toBe(false); + }); + + it('validates xrp coin codes', () => { + expect(isValidDerivationPath("m/44'/144'/0'", 'xrp')).toBe(true); + expect(isValidDerivationPath("m/44'/0'/0'", 'xrp')).toBe(true); + expect(isValidDerivationPath("m/44'/1'/0'", 'xrp')).toBe(true); + expect(isValidDerivationPath("m/44'/60'/0'", 'xrp')).toBe(false); + }); + + it('validates doge coin codes', () => { + expect(isValidDerivationPath("m/44'/3'/0'", 'doge')).toBe(true); + expect(isValidDerivationPath("m/44'/1'/0'", 'doge')).toBe(true); + expect(isValidDerivationPath("m/44'/0'/0'", 'doge')).toBe(false); + }); + + it('validates ltc coin codes', () => { + expect(isValidDerivationPath("m/44'/2'/0'", 'ltc')).toBe(true); + expect(isValidDerivationPath("m/44'/1'/0'", 'ltc')).toBe(true); + expect(isValidDerivationPath("m/44'/0'/0'", 'ltc')).toBe(false); + }); + + it('returns false for unknown chain', () => { + expect(isValidDerivationPath("m/44'/0'/0'", 'unknown')).toBe(false); + }); +}); + +describe('keyExtractor', () => { + it('returns the id property', () => { + expect(keyExtractor({id: 'abc123'})).toBe('abc123'); + expect(keyExtractor({id: 'wallet-1'})).toBe('wallet-1'); + }); +}); + +describe('getSignificantDigits', () => { + it('returns 4 for listed high-decimal currencies', () => { + expect(getSignificantDigits('doge')).toBe(4); + expect(getSignificantDigits('xrp')).toBe(4); + expect(getSignificantDigits('shib')).toBe(4); + expect(getSignificantDigits('elon')).toBe(4); + expect(getSignificantDigits('prt')).toBe(4); + expect(getSignificantDigits('rfox')).toBe(4); + expect(getSignificantDigits('rfuel')).toBe(4); + expect(getSignificantDigits('xyo')).toBe(4); + }); + + it('is case-insensitive', () => { + expect(getSignificantDigits('DOGE')).toBe(4); + expect(getSignificantDigits('XRP')).toBe(4); + }); + + it('returns undefined for other currencies', () => { + expect(getSignificantDigits('btc')).toBeUndefined(); + expect(getSignificantDigits('eth')).toBeUndefined(); + }); + + it('returns undefined when called with undefined', () => { + expect(getSignificantDigits(undefined)).toBeUndefined(); + }); +}); + +describe('formatFiatAmount', () => { + it('formats with symbol by default (USD)', () => { + const result = formatFiatAmount(100, 'USD'); + expect(result).toContain('100'); + expect(result).toContain('$'); + }); + + it('formats with code display', () => { + const result = formatFiatAmount(100, 'USD', {currencyDisplay: 'code'}); + expect(result).toContain('100'); + expect(result).toContain('USD'); + }); + + it('formats zero', () => { + const result = formatFiatAmount(0, 'USD'); + expect(result).toContain('0'); + }); + + it('applies customPrecision minimal for integers', () => { + const result = formatFiatAmount(100, 'USD', {customPrecision: 'minimal'}); + // should format without decimal fraction + expect(result).toContain('100'); + }); + + it('includes significant digits for doge', () => { + const result = formatFiatAmount(1.23456, 'USD', { + currencyAbbreviation: 'doge', + }); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); +}); + +describe('formatFiat', () => { + it('delegates to formatFiatAmount with correct arguments', () => { + const result = formatFiat({ + fiatAmount: 42, + defaultAltCurrencyIsoCode: 'USD', + }); + expect(result).toContain('42'); + }); + + it('passes through currencyDisplay code', () => { + const result = formatFiat({ + fiatAmount: 50, + defaultAltCurrencyIsoCode: 'EUR', + currencyDisplay: 'code', + }); + expect(result).toContain('EUR'); + }); +}); + +describe('findContact', () => { + const contacts = [ + { + name: 'Alice', + address: '0xabc', + coin: 'eth', + network: 'livenet', + chain: 'eth', + }, + { + name: 'Bob', + address: '0xdef', + coin: 'btc', + network: 'livenet', + chain: 'btc', + }, + ]; + + it('returns true when contact address is found', () => { + expect(findContact(contacts as any, '0xabc')).toBe(true); + }); + + it('returns false when address is not found', () => { + expect(findContact(contacts as any, '0x999')).toBe(false); + }); + + it('returns false for empty contact list', () => { + expect(findContact([], '0xabc')).toBe(false); + }); +}); + +describe('getContactObj', () => { + const contacts = [ + { + name: 'Alice', + address: '0xabc', + coin: 'eth', + network: 'livenet', + chain: 'eth', + }, + { + name: 'Bob', + address: '0xdef', + coin: 'btc', + network: 'livenet', + chain: 'btc', + }, + ]; + + it('finds and returns matching contact object', () => { + const result = getContactObj( + contacts as any, + '0xabc', + 'eth', + 'livenet', + 'eth', + ); + expect(result).toBeDefined(); + expect((result as any).name).toBe('Alice'); + }); + + it('returns undefined when no match found', () => { + const result = getContactObj( + contacts as any, + '0xabc', + 'btc', // wrong coin + 'livenet', + 'eth', + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined for empty list', () => { + expect(getContactObj([], '0xabc', 'eth', 'livenet', 'eth')).toBeUndefined(); + }); +}); + +describe('shouldScale', () => { + it('returns false for falsy values', () => { + expect(shouldScale(null)).toBe(false); + expect(shouldScale(undefined)).toBe(false); + expect(shouldScale('')).toBe(false); + expect(shouldScale(0)).toBe(false); + }); + + it('returns false when value length <= threshold', () => { + expect(shouldScale('hello', 10)).toBe(false); // 5 chars, threshold 10 + expect(shouldScale('1234567890', 10)).toBe(false); // exactly 10 + }); + + it('returns true when string length exceeds threshold', () => { + expect(shouldScale('12345678901', 10)).toBe(true); // 11 chars > 10 + }); + + it('converts number to fixed(2) string before comparing', () => { + // 12345.67 → "12345.67" which is 8 chars; default threshold 10 → false + expect(shouldScale(12345.67)).toBe(false); + // 123456789.99 → "123456789.99" which is 12 chars > 10 → true + expect(shouldScale(123456789.99)).toBe(true); + }); + + it('uses default threshold of 10', () => { + expect(shouldScale('12345678901')).toBe(true); + expect(shouldScale('1234567890')).toBe(false); + }); +}); + +describe('formatCryptoAddress', () => { + it('returns "--" for empty address', () => { + expect(formatCryptoAddress('')).toBe('--'); + expect(formatCryptoAddress(null as any)).toBe('--'); + expect(formatCryptoAddress(undefined as any)).toBe('--'); + }); + + it('formats address with first 6 and last 6 chars', () => { + const addr = '0x1234567890abcdef1234567890abcdef12345678'; + const result = formatCryptoAddress(addr); + expect(result).toBe('0x1234....345678'); + }); + + it('handles short addresses gracefully', () => { + const addr = '0x1234567890ab'; + const result = formatCryptoAddress(addr); + expect(typeof result).toBe('string'); + expect(result).toContain('....'); + }); +}); + +describe('calculatePercentageDifference', () => { + it('calculates positive percentage difference', () => { + expect(calculatePercentageDifference(110, 100)).toBe(10); + }); + + it('calculates negative percentage difference', () => { + expect(calculatePercentageDifference(90, 100)).toBe(-10); + }); + + it('returns 0 when balances are equal', () => { + expect(calculatePercentageDifference(100, 100)).toBe(0); + }); + + it('rounds to 2 decimal places', () => { + expect(calculatePercentageDifference(101, 300)).toBe(-66.33); + }); +}); + +describe('getLastDayTimestampStartOfHourMs', () => { + it('returns a number', () => { + const result = getLastDayTimestampStartOfHourMs(); + expect(typeof result).toBe('number'); + }); + + it('is exactly 24 hours before the current hour start', () => { + const now = Date.now(); + const result = getLastDayTimestampStartOfHourMs(now); + const MS_PER_HOUR = 60 * 60 * 1000; + const MS_PER_DAY = 24 * MS_PER_HOUR; + const lastDay = now - MS_PER_DAY; + const expected = Math.floor(lastDay / MS_PER_HOUR) * MS_PER_HOUR; + expect(result).toBe(expected); + }); + + it('is aligned to the hour (divisible by 3600000)', () => { + const result = getLastDayTimestampStartOfHourMs(Date.now()); + expect(result % (60 * 60 * 1000)).toBe(0); + }); +}); + +describe('formatFiatAmountObj', () => { + it('returns amount with symbol by default', () => { + const result = formatFiatAmountObj(100, 'USD'); + expect(result.amount).toContain('100'); + expect(result.code).toBeUndefined(); + }); + + it('returns amount and code when currencyDisplay is code', () => { + const result = formatFiatAmountObj(100, 'USD', {currencyDisplay: 'code'}); + expect(result.amount).toContain('100'); + expect(result.code).toBe('USD'); + }); + + it('handles zero amount', () => { + const result = formatFiatAmountObj(0, 'USD'); + expect(result.amount).toContain('0'); + }); +}); + +describe('convertToFiat', () => { + it('returns fiat value on mainnet when both hide flags are falsy', () => { + expect(convertToFiat(500, false, false, Network.mainnet)).toBe(500); + }); + + it('returns 0 when hideWallet is true', () => { + expect(convertToFiat(500, true, false, Network.mainnet)).toBe(0); + }); + + it('returns 0 when hideWalletByAccount is true', () => { + expect(convertToFiat(500, false, true, Network.mainnet)).toBe(0); + }); + + it('returns 0 on testnet even when flags are false', () => { + expect(convertToFiat(500, false, false, Network.testnet)).toBe(0); + }); + + it('returns 0 on testnet with both flags false', () => { + expect(convertToFiat(100, undefined, undefined, Network.testnet)).toBe(0); + }); +}); + +describe('getErrorString', () => { + it('returns message for Error instances', () => { + expect(getErrorString(new Error('boom'))).toBe('boom'); + }); + + it('returns the string itself for string errors', () => { + expect(getErrorString('something went wrong')).toBe('something went wrong'); + }); + + it('returns JSON-stringified form for plain objects', () => { + const err = {code: 42, reason: 'test'}; + const result = getErrorString(err); + expect(result).toBe(JSON.stringify(err)); + }); + + it('handles null gracefully', () => { + const result = getErrorString(null); + expect(typeof result).toBe('string'); + }); + + it('handles numbers', () => { + const result = getErrorString(404); + expect(typeof result).toBe('string'); + }); +}); + +describe('addTokenChainSuffix', () => { + it('lowercases name for non-SVM chains', () => { + expect(addTokenChainSuffix('USDC', 'eth')).toBe('usdc_e'); + expect(addTokenChainSuffix('DAI', 'matic')).toBe('dai_m'); + expect(addTokenChainSuffix('TOKEN', 'arb')).toBe('token_arb'); + expect(addTokenChainSuffix('TOKEN', 'base')).toBe('token_base'); + expect(addTokenChainSuffix('TOKEN', 'op')).toBe('token_op'); + }); + + it('preserves casing for SVM chains (sol)', () => { + // IsSVMChain('sol') is true, so name is NOT lowercased + expect(addTokenChainSuffix('USDC', 'sol')).toBe('USDC_sol'); + }); +}); + +describe('formatCurrencyAbbreviation', () => { + it('returns upper-cased string when no dot is present', () => { + expect(formatCurrencyAbbreviation('usdc')).toBe('USDC'); + expect(formatCurrencyAbbreviation('BTC')).toBe('BTC'); + }); + + it('formats dot-separated abbreviations (e.g. SOL.USDC)', () => { + expect(formatCurrencyAbbreviation('SOL.USDC')).toBe('SOL.usdc'); + expect(formatCurrencyAbbreviation('eth.dai')).toBe('ETH.dai'); + }); +}); + +describe('getCurrencyAbbreviation', () => { + it('lower-cases name for native chains', () => { + expect(getCurrencyAbbreviation('ETH', 'eth')).toBe('eth'); + expect(getCurrencyAbbreviation('BTC', 'btc')).toBe('btc'); + }); + + it('adds token chain suffix for ERC token (contract address)', () => { + // A contract address like '0xabc...' on eth is treated as an ERC token + const addr = '0xdac17f958d2ee523a2206206994597c13d831ec7'; // USDT address + const result = getCurrencyAbbreviation(addr, 'eth'); + expect(result).toBe(`${addr}_e`); + }); +}); + +describe('getProtocolName', () => { + it('returns chain-specific protocol name for known chain+network', () => { + expect(getProtocolName('eth', 'livenet')).toBe('Ethereum Mainnet'); + expect(getProtocolName('sol', 'livenet')).toBe('Solana'); + expect(getProtocolName('matic', 'livenet')).toBe('Polygon'); + expect(getProtocolName('arb', 'livenet')).toBe('Arbitrum'); + expect(getProtocolName('base', 'livenet')).toBe('Base'); + expect(getProtocolName('op', 'livenet')).toBe('Optimism'); + }); + + it('falls back to default for unknown chain', () => { + expect(getProtocolName('doge', 'livenet')).toBe('Mainnet'); + }); + + it('is case-insensitive', () => { + expect(getProtocolName('ETH', 'LIVENET')).toBe('Ethereum Mainnet'); + }); +}); + +describe('getEVMFeeCurrency', () => { + it('returns eth for eth chain', () => { + expect(getEVMFeeCurrency('eth')).toBe('eth'); + }); + + it('returns matic for matic chain', () => { + expect(getEVMFeeCurrency('matic')).toBe('matic'); + }); + + it('returns eth for arb chain', () => { + expect(getEVMFeeCurrency('arb')).toBe('eth'); + }); + + it('returns eth for base chain', () => { + expect(getEVMFeeCurrency('base')).toBe('eth'); + }); + + it('returns eth for op chain', () => { + expect(getEVMFeeCurrency('op')).toBe('eth'); + }); + + it('returns sol for sol chain', () => { + expect(getEVMFeeCurrency('sol')).toBe('sol'); + }); + + it('returns eth for unknown chain (default)', () => { + expect(getEVMFeeCurrency('unknown')).toBe('eth'); + }); + + it('is case-insensitive', () => { + expect(getEVMFeeCurrency('ETH')).toBe('eth'); + expect(getEVMFeeCurrency('MATIC')).toBe('matic'); + }); +}); + +describe('getCWCChain', () => { + it('returns ETHERC20 for eth', () => { + expect(getCWCChain('eth')).toBe('ETHERC20'); + }); + + it('returns MATICERC20 for matic', () => { + expect(getCWCChain('matic')).toBe('MATICERC20'); + }); + + it('returns ARBERC20 for arb', () => { + expect(getCWCChain('arb')).toBe('ARBERC20'); + }); + + it('returns BASEERC20 for base', () => { + expect(getCWCChain('base')).toBe('BASEERC20'); + }); + + it('returns OPERC20 for op', () => { + expect(getCWCChain('op')).toBe('OPERC20'); + }); + + it('returns SOLSPL for sol', () => { + expect(getCWCChain('sol')).toBe('SOLSPL'); + }); + + it('returns ETHERC20 for unknown chain (default)', () => { + expect(getCWCChain('unknown')).toBe('ETHERC20'); + }); + + it('is case-insensitive', () => { + expect(getCWCChain('ETH')).toBe('ETHERC20'); + expect(getCWCChain('SOL')).toBe('SOLSPL'); + }); +}); + +describe('getChainUsingSuffix', () => { + it('returns eth for suffix "e"', () => { + expect(getChainUsingSuffix('e')).toBe('eth'); + }); + + it('returns matic for suffix "m"', () => { + expect(getChainUsingSuffix('m')).toBe('matic'); + }); + + it('returns base for suffix "base"', () => { + expect(getChainUsingSuffix('base')).toBe('base'); + }); + + it('returns arb for suffix "arb"', () => { + expect(getChainUsingSuffix('arb')).toBe('arb'); + }); + + it('returns op for suffix "op"', () => { + expect(getChainUsingSuffix('op')).toBe('op'); + }); + + it('returns sol for suffix "sol"', () => { + expect(getChainUsingSuffix('sol')).toBe('sol'); + }); + + it('returns eth for unknown suffix (default)', () => { + expect(getChainUsingSuffix('xyz')).toBe('eth'); + }); +}); + +describe('isL2NoSideChainNetwork', () => { + it('returns true for arb', () => { + expect(isL2NoSideChainNetwork('arb')).toBe(true); + }); + + it('returns true for base', () => { + expect(isL2NoSideChainNetwork('base')).toBe(true); + }); + + it('returns true for op', () => { + expect(isL2NoSideChainNetwork('op')).toBe(true); + }); + + it('returns false for eth', () => { + expect(isL2NoSideChainNetwork('eth')).toBe(false); + }); + + it('returns false for matic', () => { + expect(isL2NoSideChainNetwork('matic')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(isL2NoSideChainNetwork('ARB')).toBe(true); + expect(isL2NoSideChainNetwork('BASE')).toBe(true); + }); +}); + +describe('camelCaseToUpperWords', () => { + it('splits camelCase into upper-cased words', () => { + expect(camelCaseToUpperWords('transferFrom')).toBe('TRANSFER FROM'); + expect(camelCaseToUpperWords('approve')).toBe('APPROVE'); + }); + + it('replaces underscores with spaces', () => { + expect(camelCaseToUpperWords('some_function_name')).toBe( + 'SOME FUNCTION NAME', + ); + }); + + it('handles already-uppercase or mixed input', () => { + expect(camelCaseToUpperWords('execute')).toBe('EXECUTE'); + expect(camelCaseToUpperWords('swapExactTokensForTokens')).toBe( + 'SWAP EXACT TOKENS FOR TOKENS', + ); + }); + + it('handles empty string', () => { + expect(camelCaseToUpperWords('')).toBe(''); + }); +}); + +describe('removeTrailingZeros', () => { + it('removes trailing zeros from hex string', () => { + expect(removeTrailingZeros('abc000')).toBe('abc'); + expect(removeTrailingZeros('abc123000')).toBe('abc123'); + }); + + it('leaves strings with no trailing zeros unchanged', () => { + expect(removeTrailingZeros('abc123')).toBe('abc123'); + }); + + it('returns empty string when all chars are zeros', () => { + expect(removeTrailingZeros('000')).toBe(''); + }); +}); + +describe('extractAddresses', () => { + it('extracts sender contract and recipient addresses from hex chunk', () => { + // Build a 86-char hex string + // senderContractAddress: chars 0-39 → 40 chars → '1'.repeat(40) + // chars 40-45 are skipped (6 chars) + // recipientAddress: chars 46-85 → 40 chars → '2'.repeat(40) + const senderPart = '1'.repeat(40); + const skip = '0'.repeat(6); + const recipientPart = '2'.repeat(40); + const hex = senderPart + skip + recipientPart; + + const {senderContractAddress, recipientAddress} = extractAddresses(hex); + + expect(senderContractAddress).toBe('0x' + senderPart); + expect(recipientAddress).toBe('0x' + recipientPart); + }); +}); + +describe('splitInputsToChunks', () => { + it('splits hex inputs (with 0x prefix) into 64-char chunks', () => { + const input = '0x' + 'a'.repeat(128); // 128 hex chars → 2 chunks of 64 + const result = splitInputsToChunks([input]); + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toBe('a'.repeat(64)); + expect(result[0][1]).toBe('a'.repeat(64)); + }); + + it('splits hex inputs without 0x prefix', () => { + const input = 'b'.repeat(64); + const result = splitInputsToChunks([input]); + expect(result).toHaveLength(1); + expect(result[0][0]).toBe('b'.repeat(64)); + }); + + it('handles multiple inputs', () => { + const a = '0x' + 'a'.repeat(64); + const b = '0x' + 'b'.repeat(128); + const result = splitInputsToChunks([a, b]); + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(1); + expect(result[1]).toHaveLength(2); + }); +}); + +describe('toggleTSSModal', () => { + it('calls setter and waits when setShowTSSProgressModal is provided', async () => { + jest.useFakeTimers(); + const mockSetter = jest.fn(); + const promise = toggleTSSModal(mockSetter, true, 100); + expect(mockSetter).toHaveBeenCalledWith(true); + jest.advanceTimersByTime(100); + await promise; + jest.useRealTimers(); + }); + + it('does nothing when setShowTSSProgressModal is undefined', async () => { + // Should resolve without error + await toggleTSSModal(undefined, true); + }); + + it('does not sleep when delayMs is 0', async () => { + const mockSetter = jest.fn(); + await toggleTSSModal(mockSetter, false, 0); + expect(mockSetter).toHaveBeenCalledWith(false); + }); +}); + +describe('sleep', () => { + it('resolves after the given duration', async () => { + jest.useFakeTimers(); + const p = sleep(500); + jest.advanceTimersByTime(500); + await p; // should not hang + jest.useRealTimers(); + }); +}); + +describe('suffixChainMap', () => { + it('contains expected chain suffixes', () => { + expect(suffixChainMap.eth).toBe('e'); + expect(suffixChainMap.matic).toBe('m'); + expect(suffixChainMap.arb).toBe('arb'); + expect(suffixChainMap.base).toBe('base'); + expect(suffixChainMap.op).toBe('op'); + expect(suffixChainMap.sol).toBe('sol'); + }); +}); + +describe('getNetworkName (additional coin codes)', () => { + it('returns livenet for XRP coin code 144', () => { + expect(getNetworkName("m/44'/144'/0'")).toBe('livenet'); + }); + + it('returns livenet for DOGE coin code 3', () => { + expect(getNetworkName("m/44'/3'/0'")).toBe('livenet'); + }); + + it('returns livenet for LTC coin code 2', () => { + expect(getNetworkName("m/44'/2'/0'")).toBe('livenet'); + }); + + it('returns livenet for SOL coin code 501', () => { + expect(getNetworkName("m/44'/501'/0'")).toBe('livenet'); + }); + + it('returns empty string for unknown coin code', () => { + expect(getNetworkName("m/44'/999'/0'")).toBe(''); + }); +}); + +// ─── Additional coverage ─────────────────────────────────────────────────────── + +describe('unitStringToAtomicBigInt (additional branches)', () => { + it('handles whitespace-only string', () => { + expect(unitStringToAtomicBigInt(' ', 8)).toBe(0n); + }); + + it('handles value with only fractional part and no integer', () => { + expect(unitStringToAtomicBigInt('.5', 8)).toBe(50000000n); + }); + + it('truncates extra decimal digits beyond unitDecimals', () => { + // 1.999999999 with 6 decimals → 1999999 + expect(unitStringToAtomicBigInt('1.999999999', 6)).toBe(1999999n); + }); + + it('handles negative zero', () => { + expect(unitStringToAtomicBigInt('-0', 8)).toBe(0n); + }); +}); + +describe('atomicToUnitString (additional branches)', () => { + it('handles 0 decimals for negative value', () => { + expect(atomicToUnitString(-42n, 0)).toBe('-42'); + }); + + it('handles value that has only fractional part (intPart=0)', () => { + expect(atomicToUnitString(5n, 8)).toBe('0.00000005'); + }); + + it('handles large values', () => { + // 21000000 * 10^8 satoshis (21 million BTC) + const big = 2100000000000000n; + expect(atomicToUnitString(big, 8)).toBe('21000000'); + }); +}); + +describe('changeOpacity (additional branches)', () => { + it('returns original color when hex length is not 3 or 6 (after removing #)', () => { + // 4-char hex after stripping '#' → length 4 → not normalized to 6 → fallthrough + expect(changeOpacity('#1234', 0.5)).toBe('#1234'); + }); + + it('handles color without # prefix that is not 6 chars', () => { + // no # prefix: hex = 'abc' which is length 3 → normalized to 6 → valid + expect(changeOpacity('abc', 0.5)).toBe('rgba(170, 187, 204, 0.5)'); + }); + + it('clamps opacity exactly at 0', () => { + expect(changeOpacity('#ffffff', 0)).toBe('rgba(255, 255, 255, 0)'); + }); +}); + +describe('transformAmount', () => { + const defaultOpts = { + fullPrecision: 'full', + decimals: { + full: {maxDecimals: 8, minDecimals: 0}, + short: {maxDecimals: 2, minDecimals: 0}, + }, + toSatoshis: 100000000, + }; + + it('converts satoshis to BTC-like string with full precision', () => { + const result = transformAmount(100000000, defaultOpts); + // trailing decimal point may appear when minDecimals=0 and frac is empty + expect(result).toMatch(/^1\.?$/); + }); + + it('converts fractional satoshi amounts', () => { + const result = transformAmount(50000000, defaultOpts); + expect(result).toBe('0.5'); + }); + + it('uses short precision when fullPrecision is falsy', () => { + const opts = {...defaultOpts, fullPrecision: ''}; + const result = transformAmount(100000000, opts); + // short decimals: max 2, minDecimals 0 + expect(result).toMatch(/^1\.?$/); + }); + + it('uses custom thousands separator', () => { + const result = transformAmount(100000000000, { + ...defaultOpts, + thousandsSeparator: '.', + decimalSeparator: ',', + }); + // 1000 BTC with thousands separator '.' → "1.000" possibly with trailing "," + expect(result).toMatch(/^1\.000,?$/); + }); + + it('handles zero satoshis', () => { + const result = transformAmount(0, defaultOpts); + // May produce "0." due to decimal separator being appended + expect(result).toMatch(/^0\.?$/); + }); + + it('trims trailing zeros in fractional output', () => { + // 150000000 satoshis = 1.5 BTC → should not show trailing zeros beyond 1.5 + const result = transformAmount(150000000, defaultOpts); + expect(result).toBe('1.5'); + }); + + it('applies minDecimals to keep minimum decimal places', () => { + const opts = { + fullPrecision: 'full', + decimals: { + full: {maxDecimals: 8, minDecimals: 2}, + short: {maxDecimals: 2, minDecimals: 2}, + }, + toSatoshis: 100000000, + }; + const result = transformAmount(100000000, opts); + // minDecimals=2 → at least "1.00" + expect(result).toBe('1.00'); + }); +}); + +describe('getMnemonic', () => { + it('splits mnemonic string on spaces', () => { + const key = {properties: {mnemonic: 'word1 word2 word3'}} as any; + expect(getMnemonic(key)).toEqual(['word1', 'word2', 'word3']); + }); + + it('trims leading/trailing whitespace from mnemonic', () => { + const key = {properties: {mnemonic: ' word1 word2 '}} as any; + expect(getMnemonic(key)).toEqual(['word1', 'word2']); + }); +}); + +describe('getRateByCurrencyName', () => { + const rates = { + eth: [{code: 'USD', name: 'US Dollar', rate: 2000}], + matic: [{code: 'USD', name: 'US Dollar', rate: 1}], + usdc_e: [{code: 'USD', name: 'US Dollar', rate: 1}], + } as any; + + it('returns rates for a plain (non-token) currency', () => { + const result = getRateByCurrencyName(rates, 'eth', 'eth'); + expect(result).toBe(rates.eth); + }); + + it('returns rates by contract address for ERC token', () => { + // A contract address on eth → getCurrencyAbbreviation returns addr_e + const contractAddr = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + const ratesWithToken = { + ...rates, + [`${contractAddr}_e`]: [{code: 'USD', name: 'US Dollar', rate: 0.99}], + } as any; + const result = getRateByCurrencyName( + ratesWithToken, + contractAddr, + 'eth', + contractAddr, + ); + expect(result).toBeDefined(); + }); + + it('falls back to rates[currencyAbbreviation] when currencyName key missing', () => { + // 'btc' is not a contract address → getCurrencyAbbreviation returns 'btc' + const ratesWithBtc = { + ...rates, + btc: [{code: 'USD', name: 'US Dollar', rate: 60000}], + } as any; + const result = getRateByCurrencyName(ratesWithBtc, 'btc', 'btc'); + expect(result).toEqual(ratesWithBtc.btc); + }); + + it('returns matic rates when currencyAbbreviation is pol and matic rates exist', () => { + const result = getRateByCurrencyName(rates, 'pol', 'matic'); + expect(result).toBe(rates.matic); + }); + + it('falls back to regular lookup when pol but no matic rates', () => { + const ratesNoPol = {eth: rates.eth} as any; + const result = getRateByCurrencyName(ratesNoPol, 'pol', 'matic'); + // getCurrencyAbbreviation('pol', 'matic') → 'pol'; rates['pol'] → undefined + expect(result).toBeUndefined(); + }); +}); + +describe('getBadgeImg', () => { + it('returns a truthy value for ERC token (contract address on eth)', () => { + const contractAddr = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + const result = getBadgeImg(contractAddr, 'eth'); + // Should return CurrencyListIcons['eth'] which is defined + expect(result).toBeDefined(); + }); + + it('returns a truthy value for L2 chain (arb)', () => { + const result = getBadgeImg('eth', 'arb'); + expect(result).toBeDefined(); + }); + + it('returns empty string for native non-L2 coin', () => { + // 'btc' on 'btc' chain: not ERC token, not L2 + const result = getBadgeImg('btc', 'btc'); + expect(result).toBe(''); + }); +}); + +describe('getChainFromTokenByAddressKey', () => { + it('returns eth for a key ending with _e', () => { + expect(getChainFromTokenByAddressKey('usdc_e')).toBe('eth'); + }); + + it('returns matic for a key ending with _m', () => { + expect(getChainFromTokenByAddressKey('dai_m')).toBe('matic'); + }); + + it('returns base for a key ending with _base', () => { + expect(getChainFromTokenByAddressKey('token_base')).toBe('base'); + }); + + it('returns arb for a key ending with _arb', () => { + expect(getChainFromTokenByAddressKey('token_arb')).toBe('arb'); + }); + + it('returns op for a key ending with _op', () => { + expect(getChainFromTokenByAddressKey('token_op')).toBe('op'); + }); + + it('returns sol for a key ending with _sol', () => { + expect(getChainFromTokenByAddressKey('token_sol')).toBe('sol'); + }); +}); + +describe('getProtocolsName', () => { + it('returns comma-separated EVM protocol names for non-SVM chain', () => { + const result = getProtocolsName('eth'); + expect(typeof result).toBe('string'); + // Should contain Ethereum Mainnet since eth is an EVM chain + expect(result).toContain('Ethereum Mainnet'); + }); + + it('returns comma-separated SVM protocol names for sol chain', () => { + const result = getProtocolsName('sol'); + expect(typeof result).toBe('string'); + expect(result).toContain('Solana'); + }); +}); + +describe('getFullLinkedWallet', () => { + it('returns linked wallet when token is set and walletId is in tokens list', () => { + const linkedWallet = {id: 'eth-wallet', tokens: ['token-wallet-id']}; + const tokenWallet = { + credentials: {token: true, walletId: 'token-wallet-id'}, + } as any; + const key = {wallets: [linkedWallet]} as any; + const result = getFullLinkedWallet(key, tokenWallet); + expect(result).toBe(linkedWallet); + }); + + it('returns undefined when wallet has no token', () => { + const wallet = { + credentials: {token: undefined, walletId: 'some-id'}, + } as any; + const key = {wallets: [{id: 'other', tokens: ['some-id']}]} as any; + const result = getFullLinkedWallet(key, wallet); + expect(result).toBeUndefined(); + }); + + it('returns undefined when no wallet has walletId in its tokens', () => { + const wallet = {credentials: {token: true, walletId: 'missing-id'}} as any; + const key = {wallets: [{id: 'other', tokens: ['some-other-id']}]} as any; + const result = getFullLinkedWallet(key, wallet); + expect(result).toBeUndefined(); + }); +}); + +describe('getVMGasWallets', () => { + const makeWallet = (chain: string, coin: string) => ({ + credentials: {chain, coin}, + }); + + it('includes ETH (EVM chain) wallet where coin is not ERC token', () => { + const wallets = [makeWallet('eth', 'eth')] as any; + const result = getVMGasWallets(wallets); + expect(result).toHaveLength(1); + }); + + it('includes SOL (SVM chain) wallet where coin is not ERC token', () => { + const wallets = [makeWallet('sol', 'sol')] as any; + const result = getVMGasWallets(wallets); + expect(result).toHaveLength(1); + }); + + it('excludes ERC token wallet on EVM chain', () => { + // A contract address is treated as an ERC token on eth + const contractAddr = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + const wallets = [makeWallet('eth', contractAddr)] as any; + const result = getVMGasWallets(wallets); + expect(result).toHaveLength(0); + }); + + it('excludes BTC (non-VM, non-SVM) wallet', () => { + const wallets = [makeWallet('btc', 'btc')] as any; + const result = getVMGasWallets(wallets); + expect(result).toHaveLength(0); + }); +}); + +describe('getEvmGasWallets', () => { + const makeWallet = (chain: string, coin: string) => ({ + credentials: {chain, coin}, + }); + + it('includes native ETH wallet', () => { + const wallets = [makeWallet('eth', 'eth')] as any; + expect(getEvmGasWallets(wallets)).toHaveLength(1); + }); + + it('excludes SOL wallet (SVM not EVM)', () => { + const wallets = [makeWallet('sol', 'sol')] as any; + expect(getEvmGasWallets(wallets)).toHaveLength(0); + }); + + it('excludes ERC token wallets on EVM chain', () => { + const contractAddr = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + const wallets = [makeWallet('eth', contractAddr)] as any; + expect(getEvmGasWallets(wallets)).toHaveLength(0); + }); +}); + +describe('getSvmGasWallets', () => { + const makeWallet = (chain: string, coin: string) => ({ + credentials: {chain, coin}, + }); + + it('includes native SOL wallet', () => { + const wallets = [makeWallet('sol', 'sol')] as any; + expect(getSvmGasWallets(wallets)).toHaveLength(1); + }); + + it('excludes ETH wallet (EVM not SVM)', () => { + const wallets = [makeWallet('eth', 'eth')] as any; + expect(getSvmGasWallets(wallets)).toHaveLength(0); + }); +}); + +describe('formatFiatAmountObj (additional branches)', () => { + it('applies customPrecision minimal for integer amounts with code display', () => { + const result = formatFiatAmountObj(100, 'USD', { + customPrecision: 'minimal', + currencyDisplay: 'code', + }); + expect(result.code).toBe('USD'); + expect(result.amount).toContain('100'); + }); + + it('applies significantDigits for high-decimal currencies with code display', () => { + const result = formatFiatAmountObj(1.23456, 'USD', { + currencyAbbreviation: 'doge', + currencyDisplay: 'code', + }); + expect(typeof result.amount).toBe('string'); + expect(result.code).toBe('USD'); + }); +}); + +describe('formatFiatAmount (additional branches)', () => { + it('formats non-integer amount with customPrecision minimal (no suppression)', () => { + // customPrecision minimal only applies maximumFractionDigits:0 for integers + const result = formatFiatAmount(100.5, 'USD', {customPrecision: 'minimal'}); + expect(result).toContain('100'); + }); + + it('handles EUR currency with symbol display', () => { + const result = formatFiatAmount(50, 'EUR'); + expect(typeof result).toBe('string'); + expect(result).toContain('50'); + }); +}); + +describe('getErrorString (additional branches)', () => { + it('handles undefined gracefully', () => { + const result = getErrorString(undefined); + expect(typeof result).toBe('string'); + }); + + it('handles boolean false', () => { + const result = getErrorString(false); + expect(typeof result).toBe('string'); + }); +}); + +describe('convertToFiat (additional branch - mainnet both undefined)', () => { + it('returns fiat when both hide flags are undefined on mainnet', () => { + expect(convertToFiat(250, undefined, undefined, Network.mainnet)).toBe(250); + }); +}); + +describe('getSignificantDigits (additional coverage)', () => { + it('returns 4 for mixed-case RFUEL', () => { + expect(getSignificantDigits('RFUEL')).toBe(4); + }); + + it('returns undefined for empty string', () => { + // empty string: toLowerCase() still gives '' which is not in the list + expect(getSignificantDigits('')).toBeUndefined(); + }); +}); diff --git a/src/utils/passkey.spec.ts b/src/utils/passkey.spec.ts new file mode 100644 index 0000000000..55a6779568 --- /dev/null +++ b/src/utils/passkey.spec.ts @@ -0,0 +1,460 @@ +/** + * Tests for src/utils/passkey.ts + * + * All network I/O (fetch, Passkey native module, BitPayIdApi) is mocked so + * that the pure orchestration logic in each exported function can be exercised + * without hitting real endpoints. + */ + +// ─── Mock react-native-passkey ─────────────────────────────────────────────── +const mockPasskeyCreate = jest.fn(); +const mockPasskeyGet = jest.fn(); +jest.mock('react-native-passkey', () => ({ + Passkey: { + create: (...args: any[]) => mockPasskeyCreate(...args), + get: (...args: any[]) => mockPasskeyGet(...args), + }, +})); + +// ─── Mock BitPayIdApi ──────────────────────────────────────────────────────── +const mockPub = 'test-pub-key'; +jest.mock('../api/bitpay', () => ({ + __esModule: true, + default: { + getInstance: () => ({identity: {pub: mockPub}}), + }, +})); + +import {Network} from '../constants'; +import { + registerPasskey, + signInWithPasskey, + removePasskey, + getPasskeyStatus, + getPasskeyCredentials, +} from './passkey'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeOkResponse(body: object): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + } as unknown as Response; +} + +function makeErrorResponse( + status: number, + statusText: string, + body?: object | string, +): Response { + const bodyStr = + body === undefined + ? '' + : typeof body === 'string' + ? body + : JSON.stringify(body); + return { + ok: false, + status, + statusText, + json: () => Promise.resolve(body), + text: () => Promise.resolve(bodyStr), + } as unknown as Response; +} + +let fetchMock: jest.MockedFunction; + +beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock; + jest.clearAllMocks(); +}); + +// ─── registerPasskey ────────────────────────────────────────────────────────── + +describe('registerPasskey', () => { + it('returns true when the server reports success', async () => { + const creationOptions = {challenge: 'abc'}; + const passkeyResult = {id: 'cred-id', response: {}}; + + fetchMock + .mockResolvedValueOnce(makeOkResponse(creationOptions)) + .mockResolvedValueOnce(makeOkResponse({success: true})); + mockPasskeyCreate.mockResolvedValue(passkeyResult); + + const result = await registerPasskey( + 'user@example.com', + Network.mainnet, + 'csrf-token', + ); + + expect(result).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('returns false when the server reports success: false', async () => { + fetchMock + .mockResolvedValueOnce(makeOkResponse({challenge: 'abc'})) + .mockResolvedValueOnce(makeOkResponse({success: false})); + mockPasskeyCreate.mockResolvedValue({id: 'cred-id'}); + + const result = await registerPasskey( + 'user@example.com', + Network.mainnet, + 'csrf-token', + ); + + expect(result).toBe(false); + }); + + it('sends the csrf token in the x-csrf-token header', async () => { + fetchMock + .mockResolvedValueOnce(makeOkResponse({challenge: 'x'})) + .mockResolvedValueOnce(makeOkResponse({success: true})); + mockPasskeyCreate.mockResolvedValue({id: 'c'}); + + await registerPasskey('u@e.com', Network.mainnet, 'my-csrf'); + + const [, firstCallOptions] = fetchMock.mock.calls[0]; + expect((firstCallOptions as RequestInit).headers).toMatchObject({ + 'x-csrf-token': 'my-csrf', + }); + }); + + it('throws when the challenge request returns a non-ok response with JSON error', async () => { + fetchMock.mockResolvedValueOnce( + makeErrorResponse(401, 'Unauthorized', {message: 'Not logged in'}), + ); + + await expect( + registerPasskey('u@e.com', Network.mainnet, 'token'), + ).rejects.toThrow('Not logged in'); + }); + + it('throws when the verify request returns a non-ok response with plain text', async () => { + fetchMock + .mockResolvedValueOnce(makeOkResponse({challenge: 'x'})) + .mockResolvedValueOnce( + makeErrorResponse(400, 'Bad Request', 'plain error text'), + ); + mockPasskeyCreate.mockResolvedValue({id: 'c'}); + + await expect( + registerPasskey('u@e.com', Network.mainnet, 'token'), + ).rejects.toThrow('plain error text'); + }); + + it('uses testnet base URL for Network.testnet', async () => { + fetchMock + .mockResolvedValueOnce(makeOkResponse({challenge: 'x'})) + .mockResolvedValueOnce(makeOkResponse({success: true})); + mockPasskeyCreate.mockResolvedValue({id: 'c'}); + + await registerPasskey('u@e.com', Network.testnet, 'token'); + + const [firstUrl] = fetchMock.mock.calls[0]; + expect(String(firstUrl)).toContain('test.bitpay.com'); + }); + + it('passes the email in the challenge request body', async () => { + fetchMock + .mockResolvedValueOnce(makeOkResponse({challenge: 'x'})) + .mockResolvedValueOnce(makeOkResponse({success: true})); + mockPasskeyCreate.mockResolvedValue({id: 'c'}); + + await registerPasskey('hello@world.com', Network.mainnet, 'token'); + + const [, callOptions] = fetchMock.mock.calls[0]; + const sentBody = JSON.parse((callOptions as RequestInit).body as string); + expect(sentBody).toEqual({email: 'hello@world.com'}); + }); + + it('passes the passkey credential in the verify request body', async () => { + const cred = {id: 'cred-123', response: {clientDataJSON: 'x'}}; + fetchMock + .mockResolvedValueOnce(makeOkResponse({challenge: 'x'})) + .mockResolvedValueOnce(makeOkResponse({success: true})); + mockPasskeyCreate.mockResolvedValue(cred); + + await registerPasskey('u@e.com', Network.mainnet, 'token'); + + const [, verifyOptions] = fetchMock.mock.calls[1]; + const sentBody = JSON.parse((verifyOptions as RequestInit).body as string); + expect(sentBody.credential).toEqual(cred); + }); +}); + +// ─── signInWithPasskey ──────────────────────────────────────────────────────── + +describe('signInWithPasskey', () => { + it('returns true when sign-in succeeds', async () => { + const authChallenge = {options: {challenge: 'ch'}}; + const passkeyResult = {id: 'cred', response: {}}; + + fetchMock + .mockResolvedValueOnce(makeOkResponse(authChallenge)) + .mockResolvedValueOnce(makeOkResponse({success: true})); + mockPasskeyGet.mockResolvedValue(passkeyResult); + + const result = await signInWithPasskey(Network.mainnet, 'csrf'); + + expect(result).toBe(true); + }); + + it('returns false when server reports success: false', async () => { + fetchMock + .mockResolvedValueOnce(makeOkResponse({options: {challenge: 'ch'}})) + .mockResolvedValueOnce(makeOkResponse({success: false})); + mockPasskeyGet.mockResolvedValue({id: 'c'}); + + const result = await signInWithPasskey(Network.mainnet, 'csrf'); + + expect(result).toBe(false); + }); + + it('includes email in the challenge body when provided', async () => { + fetchMock + .mockResolvedValueOnce(makeOkResponse({options: {challenge: 'ch'}})) + .mockResolvedValueOnce(makeOkResponse({success: true})); + mockPasskeyGet.mockResolvedValue({id: 'c'}); + + await signInWithPasskey(Network.mainnet, 'csrf', 'user@example.com'); + + const [, callOptions] = fetchMock.mock.calls[0]; + const sentBody = JSON.parse((callOptions as RequestInit).body as string); + expect(sentBody).toEqual({email: 'user@example.com'}); + }); + + it('sends empty body when email is omitted', async () => { + fetchMock + .mockResolvedValueOnce(makeOkResponse({options: {challenge: 'ch'}})) + .mockResolvedValueOnce(makeOkResponse({success: true})); + mockPasskeyGet.mockResolvedValue({id: 'c'}); + + await signInWithPasskey(Network.mainnet, 'csrf'); + + const [, callOptions] = fetchMock.mock.calls[0]; + const sentBody = JSON.parse((callOptions as RequestInit).body as string); + expect(sentBody).toEqual({}); + }); + + it('includes pubKey in the verify body', async () => { + fetchMock + .mockResolvedValueOnce(makeOkResponse({options: {challenge: 'ch'}})) + .mockResolvedValueOnce(makeOkResponse({success: true})); + mockPasskeyGet.mockResolvedValue({id: 'cred'}); + + await signInWithPasskey(Network.mainnet, 'csrf'); + + const [, verifyCallOptions] = fetchMock.mock.calls[1]; + const sentBody = JSON.parse( + (verifyCallOptions as RequestInit).body as string, + ); + expect(sentBody.pubKey).toBe(mockPub); + }); + + it('throws on a non-ok challenge response', async () => { + fetchMock.mockResolvedValueOnce( + makeErrorResponse(403, 'Forbidden', {error: 'Forbidden'}), + ); + + await expect(signInWithPasskey(Network.mainnet, 'csrf')).rejects.toThrow( + 'Forbidden', + ); + }); +}); + +// ─── removePasskey ──────────────────────────────────────────────────────────── + +describe('removePasskey', () => { + it('calls DELETE and returns the response body', async () => { + const responseBody = {success: true}; + fetchMock.mockResolvedValueOnce(makeOkResponse(responseBody)); + + const result = await removePasskey('cred-id-123', Network.mainnet, 'csrf'); + + expect(result).toEqual(responseBody); + const [url, options] = fetchMock.mock.calls[0]; + expect(String(url)).toContain('/cred-id-123'); + expect((options as RequestInit).method).toBe('DELETE'); + }); + + it('includes the credential id at the end of the URL path', async () => { + fetchMock.mockResolvedValueOnce(makeOkResponse({success: true})); + + await removePasskey('abc-def', Network.mainnet, 'csrf'); + + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toMatch(/\/abc-def$/); + }); + + it('throws on non-ok response', async () => { + fetchMock.mockResolvedValueOnce( + makeErrorResponse(404, 'Not Found', {message: 'Credential not found'}), + ); + + await expect( + removePasskey('bad-id', Network.mainnet, 'csrf'), + ).rejects.toThrow('Credential not found'); + }); + + it('sends the csrf token in the x-csrf-token header', async () => { + fetchMock.mockResolvedValueOnce(makeOkResponse({success: true})); + + await removePasskey('id-1', Network.mainnet, 'my-delete-csrf'); + + const [, callOptions] = fetchMock.mock.calls[0]; + expect((callOptions as RequestInit).headers).toMatchObject({ + 'x-csrf-token': 'my-delete-csrf', + }); + }); +}); + +// ─── getPasskeyStatus ───────────────────────────────────────────────────────── + +describe('getPasskeyStatus', () => { + it('returns passkey status from server', async () => { + fetchMock.mockResolvedValueOnce(makeOkResponse({passkey: true})); + + const result = await getPasskeyStatus( + 'user@example.com', + Network.mainnet, + 'csrf', + ); + + expect(result).toEqual({passkey: true}); + const [, options] = fetchMock.mock.calls[0]; + expect((options as RequestInit).method).toBe('GET'); + }); + + it('URL-encodes the email in the query string', async () => { + fetchMock.mockResolvedValueOnce(makeOkResponse({passkey: false})); + + await getPasskeyStatus('hello+test@foo.com', Network.mainnet, 'csrf'); + + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toContain(encodeURIComponent('hello+test@foo.com')); + }); + + it('includes the email query parameter', async () => { + fetchMock.mockResolvedValueOnce(makeOkResponse({passkey: false})); + + await getPasskeyStatus('user@example.com', Network.mainnet, 'csrf'); + + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toContain('email=user%40example.com'); + }); + + it('throws on non-ok response', async () => { + fetchMock.mockResolvedValueOnce( + makeErrorResponse(500, 'Server Error', {message: 'Internal error'}), + ); + + await expect( + getPasskeyStatus('u@e.com', Network.mainnet, 'csrf'), + ).rejects.toThrow('Internal error'); + }); + + it('uses error field from JSON body when message field is absent', async () => { + fetchMock.mockResolvedValueOnce( + makeErrorResponse(422, 'Unprocessable', {error: 'validation failed'}), + ); + + await expect( + getPasskeyStatus('u@e.com', Network.mainnet, 'csrf'), + ).rejects.toThrow('validation failed'); + }); + + it('uses statusText as fallback when body is empty', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: () => Promise.resolve(''), + } as unknown as Response); + + await expect( + getPasskeyStatus('u@e.com', Network.mainnet, 'csrf'), + ).rejects.toThrow('Service Unavailable'); + }); + + it('attaches status, url, and body to the thrown error', async () => { + fetchMock.mockResolvedValueOnce( + makeErrorResponse(400, 'Bad Request', {message: 'bad stuff', code: 42}), + ); + + let thrown: any; + try { + await getPasskeyStatus('u@e.com', Network.mainnet, 'csrf'); + } catch (e) { + thrown = e; + } + + expect(thrown.status).toBe(400); + expect(thrown.url).toContain('bitpay.com'); + expect(thrown.body).toMatchObject({message: 'bad stuff', code: 42}); + }); +}); + +// ─── getPasskeyCredentials ──────────────────────────────────────────────────── + +describe('getPasskeyCredentials', () => { + it('returns credentials list from server', async () => { + const credentials = [ + {id: 'c1', name: 'iPhone'}, + {id: 'c2', name: 'Mac'}, + ]; + fetchMock.mockResolvedValueOnce(makeOkResponse({credentials})); + + const result = await getPasskeyCredentials( + 'user@example.com', + Network.mainnet, + 'csrf', + ); + + expect(result).toEqual({credentials}); + }); + + it('uses GET method', async () => { + fetchMock.mockResolvedValueOnce(makeOkResponse({credentials: []})); + + await getPasskeyCredentials('u@e.com', Network.mainnet, 'csrf'); + + const [, options] = fetchMock.mock.calls[0]; + expect((options as RequestInit).method).toBe('GET'); + }); + + it('URL-encodes the email in the query string', async () => { + fetchMock.mockResolvedValueOnce(makeOkResponse({credentials: []})); + + await getPasskeyCredentials('user+tag@domain.io', Network.mainnet, 'csrf'); + + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toContain(encodeURIComponent('user+tag@domain.io')); + }); + + it('throws on non-ok response', async () => { + fetchMock.mockResolvedValueOnce( + makeErrorResponse(401, 'Unauthorized', 'Unauthorized'), + ); + + await expect( + getPasskeyCredentials('u@e.com', Network.mainnet, 'csrf'), + ).rejects.toThrow('Unauthorized'); + }); + + it('sends the csrf token in the x-csrf-token header', async () => { + fetchMock.mockResolvedValueOnce(makeOkResponse({credentials: []})); + + await getPasskeyCredentials('u@e.com', Network.mainnet, 'get-csrf'); + + const [, callOptions] = fetchMock.mock.calls[0]; + expect((callOptions as RequestInit).headers).toMatchObject({ + 'x-csrf-token': 'get-csrf', + }); + }); +}); diff --git a/src/utils/password.spec.ts b/src/utils/password.spec.ts new file mode 100644 index 0000000000..2f1570eb08 --- /dev/null +++ b/src/utils/password.spec.ts @@ -0,0 +1,102 @@ +import { + isCommonWeakPassword, + isBasedOnUserData, + isLowEntropy, +} from './password'; + +describe('isCommonWeakPassword', () => { + it('returns true for empty/short strings', () => { + expect(isCommonWeakPassword('')).toBe(true); + expect(isCommonWeakPassword('abc')).toBe(true); + expect(isCommonWeakPassword('ab')).toBe(true); + }); + + it('returns true for common weak passwords', () => { + expect(isCommonWeakPassword('password')).toBe(true); + expect(isCommonWeakPassword('qwerty')).toBe(true); + expect(isCommonWeakPassword('letmein')).toBe(true); + expect(isCommonWeakPassword('welcome')).toBe(true); + expect(isCommonWeakPassword('admin')).toBe(true); + expect(isCommonWeakPassword('iloveyou')).toBe(true); + expect(isCommonWeakPassword('dragon')).toBe(true); + expect(isCommonWeakPassword('monkey')).toBe(true); + expect(isCommonWeakPassword('login')).toBe(true); + }); + + it('returns true for leet-speak variations of weak base words', () => { + // normalizeForWeakCheck strips non-alnum and applies leet substitutions + // p4ssword → p4ssword → onlyAlnum → p4ssword → deLeet(4→a) → password + expect(isCommonWeakPassword('p4ssword')).toBe(true); + // 1337 → leet → checks base words + expect(isCommonWeakPassword('dr4gon')).toBe(true); + }); + + it('returns true for passwords starting with weak words', () => { + expect(isCommonWeakPassword('password123')).toBe(true); + expect(isCommonWeakPassword('admin2024')).toBe(true); + }); + + it('returns true when normalized form contains an alphabetic sequential run', () => { + // 'myabcdpass' → normalized 'myabcdpass' → contains 'abcd' (sequential) + expect(isCommonWeakPassword('myabcdpass')).toBe(true); + }); + + it('returns false for strong passwords', () => { + expect(isCommonWeakPassword('X7#mK9@qL')).toBe(false); + expect(isCommonWeakPassword('correct-horse-battery')).toBe(false); + expect(isCommonWeakPassword('Tr0ub4dor&3')).toBe(false); + }); +}); + +describe('isBasedOnUserData', () => { + it('returns true when password contains email local part', () => { + expect( + isBasedOnUserData('johnsmith99', {email: 'johnsmith@example.com'}), + ).toBe(true); + }); + + it('returns true when password contains given name', () => { + expect(isBasedOnUserData('alice2024', {givenName: 'Alice'})).toBe(true); + }); + + it('returns true when password contains family name', () => { + expect(isBasedOnUserData('smithsecure', {familyName: 'Smith'})).toBe(true); + }); + + it('returns false when password does not contain user data', () => { + expect( + isBasedOnUserData('X7#mK9@qL', { + email: 'alice@example.com', + givenName: 'Alice', + familyName: 'Smith', + }), + ).toBe(false); + }); + + it('ignores user data pieces shorter than 3 chars', () => { + expect(isBasedOnUserData('abcdef', {givenName: 'ab'})).toBe(false); + expect(isBasedOnUserData('abcdef', {familyName: 'cd'})).toBe(false); + }); + + it('returns false when no opts are provided', () => { + expect(isBasedOnUserData('anypassword', {})).toBe(false); + }); +}); + +describe('isLowEntropy', () => { + it('returns true when there are 3 or fewer unique characters', () => { + expect(isLowEntropy('aaa')).toBe(true); + expect(isLowEntropy('ababab')).toBe(true); + expect(isLowEntropy('abcabc')).toBe(true); + }); + + it('returns true for long runs of the same character', () => { + expect(isLowEntropy('aaaaaA1!')).toBe(true); + expect(isLowEntropy('bbbbb123')).toBe(true); + }); + + it('returns false for high-entropy strings', () => { + expect(isLowEntropy('X7#mK9@qL')).toBe(false); + expect(isLowEntropy('Tr0ub4dor&3')).toBe(false); + }); +}); diff --git a/src/utils/pin.spec.ts b/src/utils/pin.spec.ts new file mode 100644 index 0000000000..c7dfe78797 --- /dev/null +++ b/src/utils/pin.spec.ts @@ -0,0 +1,145 @@ +import { + validatePin, + createPin, + verifyAndMigratePin, + hashPinLegacy, + PIN_CONFIG, +} from './pin'; + +describe('PIN_CONFIG', () => { + it('has expected constants', () => { + expect(PIN_CONFIG.LENGTH).toBe(4); + expect(PIN_CONFIG.ATTEMPT_LIMIT).toBe(3); + expect(PIN_CONFIG.PIN_MAX_VALUE).toBe(9); + expect(PIN_CONFIG.PIN_MIN_VALUE).toBe(0); + }); +}); + +describe('validatePin', () => { + it('rejects empty/missing PIN', () => { + expect(validatePin('').isValid).toBe(false); + expect(validatePin(null as any).isValid).toBe(false); + }); + + it('rejects PINs that are not exactly 4 digits', () => { + expect(validatePin('123').isValid).toBe(false); + expect(validatePin('12345').isValid).toBe(false); + }); + + it('rejects non-numeric PINs', () => { + expect(validatePin('12ab').isValid).toBe(false); + expect(validatePin('ab12').isValid).toBe(false); + }); + + it('rejects all-same-digit PINs', () => { + expect(validatePin('0000').isValid).toBe(false); + expect(validatePin('1111').isValid).toBe(false); + expect(validatePin('9999').isValid).toBe(false); + }); + + it('rejects sequential PINs', () => { + expect(validatePin('1234').isValid).toBe(false); + expect(validatePin('4321').isValid).toBe(false); + expect(validatePin('0123').isValid).toBe(false); + }); + + it('rejects known weak PINs', () => { + expect(validatePin('1212').isValid).toBe(false); + expect(validatePin('1004').isValid).toBe(false); + expect(validatePin('2580').isValid).toBe(false); + }); + + it('accepts a valid strong PIN', () => { + const result = validatePin('3729'); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts other valid PINs', () => { + expect(validatePin('8472').isValid).toBe(true); + expect(validatePin('5190').isValid).toBe(true); + }); + + it('returns an errors array with messages for invalid PINs', () => { + const result = validatePin('1234'); + expect(result.errors.length).toBeGreaterThan(0); + expect(typeof result.errors[0]).toBe('string'); + }); +}); + +describe('createPin', () => { + it('returns a salt and hashedPin for a valid PIN', () => { + const result = createPin('3729'); + expect(result.salt).toBeTruthy(); + expect(result.hashedPin).toBeTruthy(); + expect(typeof result.salt).toBe('string'); + expect(typeof result.hashedPin).toBe('string'); + }); + + it('accepts array format', () => { + const result = createPin(['3', '7', '2', '9']); + expect(result.salt).toBeTruthy(); + expect(result.hashedPin).toBeTruthy(); + }); + + it('throws for a weak PIN', () => { + expect(() => createPin('1234')).toThrow(); + expect(() => createPin('0000')).toThrow(); + }); + + it('skips validation when noVerify is true', () => { + expect(() => createPin('1234', true)).not.toThrow(); + }); + + it('generates a unique salt each call', () => { + const a = createPin('3729'); + const b = createPin('3729'); + expect(a.salt).not.toBe(b.salt); + expect(a.hashedPin).not.toBe(b.hashedPin); + }); +}); + +describe('verifyAndMigratePin', () => { + it('returns isValid false when no stored hash', () => { + const result = verifyAndMigratePin('3729', ''); + expect(result.isValid).toBe(false); + expect(result.needsMigration).toBe(false); + }); + + it('verifies a PBKDF2 (new-format) PIN correctly', () => { + const {salt, hashedPin} = createPin('3729'); + const result = verifyAndMigratePin('3729', hashedPin, salt); + expect(result.isValid).toBe(true); + expect(result.needsMigration).toBe(false); + }); + + it('rejects wrong PIN against PBKDF2 hash', () => { + const {salt, hashedPin} = createPin('3729'); + const result = verifyAndMigratePin('8472', hashedPin, salt); + expect(result.isValid).toBe(false); + }); + + it('verifies a legacy SHA-256 PIN and triggers migration', () => { + const pin = ['3', '7', '2', '9']; + const legacyHash = hashPinLegacy(pin); + const result = verifyAndMigratePin('3729', legacyHash); + expect(result.isValid).toBe(true); + expect(result.needsMigration).toBe(true); + expect(result.salt).toBeTruthy(); + expect(result.hashedPin).toBeTruthy(); + }); + + it('rejects wrong PIN against legacy hash', () => { + const pin = ['3', '7', '2', '9']; + const legacyHash = hashPinLegacy(pin); + const result = verifyAndMigratePin('8472', legacyHash); + expect(result.isValid).toBe(false); + expect(result.needsMigration).toBe(false); + }); + + it('accepts array-format PIN for PBKDF2 verification', () => { + const {salt, hashedPin} = createPin('3729'); + const result = verifyAndMigratePin(['3', '7', '2', '9'], hashedPin, salt); + expect(result.isValid).toBe(true); + }); +}); diff --git a/src/utils/portfolio/assets.pure.spec.ts b/src/utils/portfolio/assets.pure.spec.ts new file mode 100644 index 0000000000..d787c42d47 --- /dev/null +++ b/src/utils/portfolio/assets.pure.spec.ts @@ -0,0 +1,1848 @@ +/** + * Tests for pure utility functions in src/utils/portfolio/assets.ts + * + * These functions have no I/O side effects and can be tested with concrete + * inputs and exact expected outputs, giving high-confidence regression coverage. + */ + +// ─── Module mocks (same pattern as assets.getWalletIdsToPopulateFromSnapshots.spec.ts) ─── + +jest.mock('../../constants', () => ({ + Network: { + mainnet: 'livenet', + }, +})); + +jest.mock('../../constants/currencies', () => ({ + BitpaySupportedCoins: { + btc: {unitInfo: {unitDecimals: 8, unitToSatoshi: 1e8}}, + eth: {unitInfo: {unitDecimals: 18, unitToSatoshi: 1e18}}, + }, + BitpaySupportedUtxoCoins: { + btc: {unitInfo: {unitDecimals: 8, unitToSatoshi: 1e8}}, + }, + BitpaySupportedTokens: {}, +})); + +jest.mock('./rate', () => ({ + getFiatRateBaselineTsForTimeframe: jest.fn(), + getFiatRateFromSeriesCacheAtTimestamp: jest.fn(), +})); + +jest.mock('./core/pnl/analysis', () => ({ + buildPnlAnalysisSeries: jest.fn(() => ({points: []})), +})); + +jest.mock('./core/pnl/rates', () => ({ + normalizeFiatRateSeriesCoin: jest.fn((c: string) => (c || '').toLowerCase()), +})); + +jest.mock('./core/format', () => ({ + formatBigIntDecimal: jest.fn(() => '0'), +})); + +jest.mock('../../store/rate/rate.models', () => ({ + hasValidSeriesForCoin: jest.fn(() => false), +})); + +jest.mock('../../managers/TokenManager', () => ({ + tokenManager: { + getTokenOptions: () => ({tokenDataByAddress: {}}), + }, +})); + +jest.mock('../helper-methods', () => { + const unitStringToAtomicBigInt = ( + unitString: string, + unitDecimals: number, + ): bigint => { + const raw = String(unitString || '0') + .replace(/,/g, '') + .trim(); + if (!raw) return 0n; + const isNegative = raw.startsWith('-'); + const unsigned = raw.replace(/^[-+]/, ''); + const [wholeRaw, fractionRaw = ''] = unsigned.split('.'); + const whole = wholeRaw || '0'; + const fraction = fractionRaw + .padEnd(unitDecimals, '0') + .slice(0, unitDecimals); + const combined = `${whole}${fraction}`.replace(/^0+(?=\d)/, '') || '0'; + const atomic = BigInt(combined); + return isNegative ? -atomic : atomic; + }; + + return { + formatCurrencyAbbreviation: (v: string) => v, + formatFiatAmount: () => '0', + atomicToUnitString: () => '0', + getCurrencyAbbreviation: (name: string, chain: string) => { + const _name = String(name || '').toLowerCase(); + const _chain = String(chain || '').toLowerCase(); + const suffixByChain: {[c: string]: string} = { + eth: 'e', + matic: 'm', + arb: 'arb', + base: 'base', + op: 'op', + sol: 'sol', + }; + const isToken = (_name !== _chain && _name !== 'eth') || _chain === 'sol'; + return isToken ? `${_name}_${suffixByChain[_chain] || _chain}` : _name; + }, + calculatePercentageDifference: jest.fn(() => 0), + getRateByCurrencyName: () => undefined, + unitStringToAtomicBigInt, + }; +}); + +import { + sortAssetRowItemsByHasRate, + getDisplayAssetRowItems, + getQuoteCurrency, + getPercentageDifferenceFromPercentRatio, + getKeyLastDayPercentageDifference, + getLatestSnapshot, + hasSnapshotsForWallets, + hasSnapshotsBeforeMsForWallets, + buildWalletIdsByAssetGroupKey, + isPopulateLoadingForWallets, + getLegacyPercentageDifferenceFromTotals, + getVisibleKeysFromKeys, + getVisibleWalletsFromKeys, + isFiatLoadingForWallets, + getWalletLiveAtomicBalance, + walletHasNonZeroLiveBalance, + getSnapshotAtomicBalanceFromCryptoBalance, + canNavigateToExchangeRateForAssetRowItem, + getPopulateLoadingByAssetKey, + findSupportedCurrencyOptionForAsset, + getWalletIdsToPopulateFromSnapshots, + getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots, + buildPortfolioGainLossSummaryFromPortfolioSnapshots, + buildAssetRowItemsFromPortfolioSnapshots, + type AssetRowItem, +} from './assets'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const makeItem = ( + key: string, + hasRate: boolean, + overrides: Partial = {}, +): AssetRowItem => ({ + key, + currencyAbbreviation: 'btc', + chain: 'btc', + name: key, + cryptoAmount: '0', + fiatAmount: '0', + deltaFiat: '0', + deltaPercent: '0', + isPositive: true, + hasRate, + hasPnl: false, + ...overrides, +}); + +const makeWallet = ( + id: string, + network = 'livenet', + currencyAbbreviation = 'btc', +) => ({id, network, currencyAbbreviation} as any); + +// ─── sortAssetRowItemsByHasRate ──────────────────────────────────────────────── + +describe('sortAssetRowItemsByHasRate', () => { + it('returns an empty array for an empty input', () => { + expect(sortAssetRowItemsByHasRate([])).toEqual([]); + }); + + it('returns single-element array unchanged', () => { + const item = makeItem('a', true); + expect(sortAssetRowItemsByHasRate([item])).toEqual([item]); + }); + + it('puts hasRate=true items before hasRate=false items', () => { + const noRate = makeItem('no', false); + const withRate = makeItem('yes', true); + const result = sortAssetRowItemsByHasRate([noRate, withRate]); + expect(result[0].key).toBe('yes'); + expect(result[1].key).toBe('no'); + }); + + it('preserves relative order within each group', () => { + const items = [ + makeItem('no1', false), + makeItem('yes1', true), + makeItem('no2', false), + makeItem('yes2', true), + ]; + const result = sortAssetRowItemsByHasRate(items); + expect(result.map(i => i.key)).toEqual(['yes1', 'yes2', 'no1', 'no2']); + }); + + it('returns all items when all have rates', () => { + const items = [ + makeItem('a', true), + makeItem('b', true), + makeItem('c', true), + ]; + const result = sortAssetRowItemsByHasRate(items); + expect(result.map(i => i.key)).toEqual(['a', 'b', 'c']); + }); + + it('returns all items when none have rates', () => { + const items = [makeItem('a', false), makeItem('b', false)]; + const result = sortAssetRowItemsByHasRate(items); + expect(result.map(i => i.key)).toEqual(['a', 'b']); + }); + + it('does not mutate the original array', () => { + const items = [makeItem('no', false), makeItem('yes', true)]; + const copy = [...items]; + sortAssetRowItemsByHasRate(items); + expect(items).toEqual(copy); + }); +}); + +// ─── getDisplayAssetRowItems ────────────────────────────────────────────────── + +describe('getDisplayAssetRowItems', () => { + it('returns a sorted list (same semantics as sortAssetRowItemsByHasRate)', () => { + const items = [makeItem('no', false), makeItem('yes', true)]; + const result = getDisplayAssetRowItems(items); + expect(result[0].key).toBe('yes'); + expect(result[1].key).toBe('no'); + }); + + it('handles empty array', () => { + expect(getDisplayAssetRowItems([])).toEqual([]); + }); +}); + +// ─── getQuoteCurrency ───────────────────────────────────────────────────────── + +describe('getQuoteCurrency', () => { + it('returns defaultAltCurrencyIsoCode when provided', () => { + expect( + getQuoteCurrency({ + defaultAltCurrencyIsoCode: 'EUR', + portfolioQuoteCurrency: 'GBP', + }), + ).toBe('EUR'); + }); + + it('returns portfolioQuoteCurrency when defaultAltCurrencyIsoCode is absent', () => { + expect(getQuoteCurrency({portfolioQuoteCurrency: 'GBP'})).toBe('GBP'); + }); + + it('falls back to "USD" when both are absent', () => { + expect(getQuoteCurrency({})).toBe('USD'); + }); + + it('falls back to "USD" when portfolioQuoteCurrency is undefined', () => { + expect(getQuoteCurrency({portfolioQuoteCurrency: undefined})).toBe('USD'); + }); +}); + +// ─── getPercentageDifferenceFromPercentRatio ────────────────────────────────── + +describe('getPercentageDifferenceFromPercentRatio', () => { + it('converts 0.05 ratio to 5.00', () => { + expect(getPercentageDifferenceFromPercentRatio(0.05)).toBe(5); + }); + + it('converts 1.0 ratio to 100.00', () => { + expect(getPercentageDifferenceFromPercentRatio(1.0)).toBe(100); + }); + + it('converts 0 to 0', () => { + expect(getPercentageDifferenceFromPercentRatio(0)).toBe(0); + }); + + it('handles negative ratios', () => { + expect(getPercentageDifferenceFromPercentRatio(-0.1)).toBe(-10); + }); + + it('rounds to 2 decimal places', () => { + // 1/3 * 100 = 33.333... → rounds to 33.33 + expect(getPercentageDifferenceFromPercentRatio(1 / 3)).toBe(33.33); + }); + + it('returns null for Infinity', () => { + expect(getPercentageDifferenceFromPercentRatio(Infinity)).toBeNull(); + }); + + it('returns null for NaN', () => { + expect(getPercentageDifferenceFromPercentRatio(NaN)).toBeNull(); + }); + + it('returns null for -Infinity', () => { + expect(getPercentageDifferenceFromPercentRatio(-Infinity)).toBeNull(); + }); + + it('handles small ratios accurately', () => { + expect(getPercentageDifferenceFromPercentRatio(0.001)).toBe(0.1); + }); +}); + +// ─── getKeyLastDayPercentageDifference ─────────────────────────────────────── + +describe('getKeyLastDayPercentageDifference', () => { + const base = { + totalBalance: 100, + hasSnapshots: true, + hasSnapshotsBeforePopulateStarted: true, + isPopulateLoading: false, + legacyPercentageDifference: 5, + portfolioPercentageDifference: 10, + }; + + it('returns null when totalBalance is 0', () => { + expect( + getKeyLastDayPercentageDifference({...base, totalBalance: 0}), + ).toBeNull(); + }); + + it('returns null when totalBalance is negative', () => { + expect( + getKeyLastDayPercentageDifference({...base, totalBalance: -1}), + ).toBeNull(); + }); + + it('returns legacyPercentageDifference when hasSnapshots is false', () => { + expect( + getKeyLastDayPercentageDifference({...base, hasSnapshots: false}), + ).toBe(5); + }); + + it('returns legacyPercentageDifference when populate is loading and no prior snapshots', () => { + expect( + getKeyLastDayPercentageDifference({ + ...base, + isPopulateLoading: true, + hasSnapshotsBeforePopulateStarted: false, + }), + ).toBe(5); + }); + + it('returns portfolioPercentageDifference when all conditions are met', () => { + expect(getKeyLastDayPercentageDifference(base)).toBe(10); + }); + + it('returns legacyPercentageDifference when portfolioPercentageDifference is null', () => { + expect( + getKeyLastDayPercentageDifference({ + ...base, + portfolioPercentageDifference: null, + }), + ).toBe(5); + }); + + it('returns portfolio value of 0 when it is explicitly 0', () => { + expect( + getKeyLastDayPercentageDifference({ + ...base, + portfolioPercentageDifference: 0, + }), + ).toBe(0); + }); + + it('returns portfolio value of -3 (negative % change)', () => { + expect( + getKeyLastDayPercentageDifference({ + ...base, + portfolioPercentageDifference: -3, + }), + ).toBe(-3); + }); + + it('uses portfolio value even when populate is loading if prior snapshots exist', () => { + expect( + getKeyLastDayPercentageDifference({ + ...base, + isPopulateLoading: true, + hasSnapshotsBeforePopulateStarted: true, + }), + ).toBe(10); + }); +}); + +// ─── getLatestSnapshot ─────────────────────────────────────────────────────── + +describe('getLatestSnapshot', () => { + it('returns undefined for undefined input', () => { + expect(getLatestSnapshot(undefined)).toBeUndefined(); + }); + + it('returns undefined for empty array', () => { + expect(getLatestSnapshot([])).toBeUndefined(); + }); + + it('returns the last element of a single-item array', () => { + expect(getLatestSnapshot(['a'])).toBe('a'); + }); + + it('returns the last element of a multi-item array', () => { + expect(getLatestSnapshot([1, 2, 3])).toBe(3); + }); + + it('returns the last element for object snapshots', () => { + const snaps = [{ts: 1}, {ts: 2}, {ts: 3}]; + expect(getLatestSnapshot(snaps)).toEqual({ts: 3}); + }); +}); + +// ─── hasSnapshotsForWallets ─────────────────────────────────────────────────── + +describe('hasSnapshotsForWallets', () => { + it('returns false when wallets is empty', () => { + expect(hasSnapshotsForWallets({snapshotsByWalletId: {}, wallets: []})).toBe( + false, + ); + }); + + it('returns false when wallets is undefined', () => { + expect( + hasSnapshotsForWallets({snapshotsByWalletId: {}, wallets: undefined}), + ).toBe(false); + }); + + it('returns false when no wallet has snapshots', () => { + const wallets = [makeWallet('w1'), makeWallet('w2')]; + expect(hasSnapshotsForWallets({snapshotsByWalletId: {}, wallets})).toBe( + false, + ); + }); + + it('returns false when snapshot array is empty', () => { + const wallets = [makeWallet('w1')]; + expect( + hasSnapshotsForWallets({ + snapshotsByWalletId: {w1: []}, + wallets, + }), + ).toBe(false); + }); + + it('returns true when at least one wallet has a non-empty snapshot array', () => { + const wallets = [makeWallet('w1'), makeWallet('w2')]; + expect( + hasSnapshotsForWallets({ + snapshotsByWalletId: {w1: [], w2: [{timestamp: 1} as any]}, + wallets, + }), + ).toBe(true); + }); +}); + +// ─── hasSnapshotsBeforeMsForWallets ────────────────────────────────────────── + +describe('hasSnapshotsBeforeMsForWallets', () => { + const MS = 1_000_000; + + it('returns false when wallets is undefined', () => { + expect( + hasSnapshotsBeforeMsForWallets({ + snapshotsByWalletId: {}, + wallets: undefined, + cutoffMs: MS, + }), + ).toBe(false); + }); + + it('returns false when no snapshots exist', () => { + expect( + hasSnapshotsBeforeMsForWallets({ + snapshotsByWalletId: {}, + wallets: [makeWallet('w1')], + cutoffMs: MS, + }), + ).toBe(false); + }); + + it('returns false when all snapshots are after the cutoff', () => { + expect( + hasSnapshotsBeforeMsForWallets({ + snapshotsByWalletId: {w1: [{createdAt: MS + 1} as any]}, + wallets: [makeWallet('w1')], + cutoffMs: MS, + }), + ).toBe(false); + }); + + it('returns true when a snapshot has createdAt before the cutoff', () => { + expect( + hasSnapshotsBeforeMsForWallets({ + snapshotsByWalletId: {w1: [{createdAt: MS - 1} as any]}, + wallets: [makeWallet('w1')], + cutoffMs: MS, + }), + ).toBe(true); + }); + + it('returns true when a snapshot has no createdAt (treated as before cutoff)', () => { + expect( + hasSnapshotsBeforeMsForWallets({ + snapshotsByWalletId: {w1: [{} as any]}, + wallets: [makeWallet('w1')], + cutoffMs: MS, + }), + ).toBe(true); + }); + + it('returns true when createdAt is exactly the cutoff value (not strictly before)', () => { + // cutoff check is `createdAt < cutoff`, so equal means false + expect( + hasSnapshotsBeforeMsForWallets({ + snapshotsByWalletId: {w1: [{createdAt: MS} as any]}, + wallets: [makeWallet('w1')], + cutoffMs: MS, + }), + ).toBe(false); + }); +}); + +// ─── buildWalletIdsByAssetGroupKey ──────────────────────────────────────────── + +describe('buildWalletIdsByAssetGroupKey', () => { + it('returns empty object for undefined wallets', () => { + expect(buildWalletIdsByAssetGroupKey(undefined)).toEqual({}); + }); + + it('returns empty object for empty wallets array', () => { + expect(buildWalletIdsByAssetGroupKey([])).toEqual({}); + }); + + it('groups wallet ids by lowercased currencyAbbreviation', () => { + const wallets = [ + makeWallet('w1', 'livenet', 'BTC'), + makeWallet('w2', 'livenet', 'BTC'), + makeWallet('w3', 'livenet', 'eth'), + ]; + const result = buildWalletIdsByAssetGroupKey(wallets); + expect(result['btc']).toEqual(['w1', 'w2']); + expect(result['eth']).toEqual(['w3']); + }); + + it('skips wallets that are not on mainnet (livenet)', () => { + const wallets = [ + makeWallet('w1', 'testnet', 'btc'), + makeWallet('w2', 'livenet', 'btc'), + ]; + const result = buildWalletIdsByAssetGroupKey(wallets); + expect(result['btc']).toEqual(['w2']); + }); + + it('skips wallets with no id', () => { + const wallets = [ + {network: 'livenet', currencyAbbreviation: 'btc'} as any, // no id + makeWallet('w2', 'livenet', 'btc'), + ]; + const result = buildWalletIdsByAssetGroupKey(wallets); + expect(result['btc']).toEqual(['w2']); + }); + + it('skips wallets with no currencyAbbreviation', () => { + const wallets = [ + {id: 'w1', network: 'livenet', currencyAbbreviation: ''} as any, + makeWallet('w2', 'livenet', 'eth'), + ]; + const result = buildWalletIdsByAssetGroupKey(wallets); + expect(result['eth']).toEqual(['w2']); + expect(result['']).toBeUndefined(); + }); +}); + +// ─── isPopulateLoadingForWallets ────────────────────────────────────────────── + +describe('isPopulateLoadingForWallets', () => { + it('returns false when populateStatus is undefined', () => { + expect( + isPopulateLoadingForWallets({ + populateStatus: undefined, + wallets: [makeWallet('w1')], + }), + ).toBe(false); + }); + + it('returns false when populateStatus.inProgress is false', () => { + expect( + isPopulateLoadingForWallets({ + populateStatus: { + inProgress: false, + walletStatusById: {}, + walletsTotal: 0, + } as any, + wallets: [makeWallet('w1')], + }), + ).toBe(false); + }); + + it('returns false when no relevant wallets are in scope', () => { + expect( + isPopulateLoadingForWallets({ + populateStatus: { + inProgress: true, + walletStatusById: {other_wallet: 'in_progress'}, + walletsTotal: 1, + } as any, + wallets: [makeWallet('w1')], // w1 not in statusById + }), + ).toBe(false); + }); + + it('returns false when wallets is undefined', () => { + expect( + isPopulateLoadingForWallets({ + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'in_progress'}, + walletsTotal: 1, + } as any, + wallets: undefined, + }), + ).toBe(false); + }); + + it('returns true when a wallet is the currentWalletId (regardless of status)', () => { + expect( + isPopulateLoadingForWallets({ + populateStatus: { + inProgress: true, + currentWalletId: 'w1', + walletStatusById: {}, + walletsTotal: 1, + } as any, + wallets: [makeWallet('w1')], + }), + ).toBe(true); + }); + + it('returns true when a wallet has status "in_progress"', () => { + expect( + isPopulateLoadingForWallets({ + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'in_progress'}, + walletsTotal: 1, + } as any, + wallets: [makeWallet('w1')], + }), + ).toBe(true); + }); + + it('returns false when all relevant wallets have status "done"', () => { + expect( + isPopulateLoadingForWallets({ + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'done'}, + walletsTotal: 1, + } as any, + wallets: [makeWallet('w1')], + }), + ).toBe(false); + }); + + it('returns true when at least one wallet is in_progress among multiple', () => { + expect( + isPopulateLoadingForWallets({ + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'done', w2: 'in_progress'}, + walletsTotal: 2, + } as any, + wallets: [makeWallet('w1'), makeWallet('w2')], + }), + ).toBe(true); + }); +}); + +// ─── getLegacyPercentageDifferenceFromTotals ────────────────────────────────── + +describe('getLegacyPercentageDifferenceFromTotals', () => { + it('returns null when totalBalanceLastDay is 0', () => { + expect( + getLegacyPercentageDifferenceFromTotals({ + totalBalance: 100, + totalBalanceLastDay: 0, + }), + ).toBeNull(); + }); + + it('returns null when totalBalanceLastDay is undefined', () => { + expect( + getLegacyPercentageDifferenceFromTotals({ + totalBalance: 100, + totalBalanceLastDay: undefined, + }), + ).toBeNull(); + }); + + it('delegates to calculatePercentageDifference when lastDay > 0', () => { + // calculatePercentageDifference is mocked to return 0, but we confirm it's called + const result = getLegacyPercentageDifferenceFromTotals({ + totalBalance: 110, + totalBalanceLastDay: 100, + }); + // Mock returns 0 + expect(result).toBe(0); + }); +}); + +// ─── getVisibleKeysFromKeys ─────────────────────────────────────────────────── + +describe('getVisibleKeysFromKeys', () => { + const makeKey = (id: string, wallets: any[] = []) => ({id, wallets} as any); + + it('returns empty array when keys is undefined', () => { + expect(getVisibleKeysFromKeys(undefined)).toEqual([]); + }); + + it('returns all keys when no homeCarouselConfig is provided', () => { + const keys = {k1: makeKey('k1'), k2: makeKey('k2')}; + const result = getVisibleKeysFromKeys(keys); + expect(result).toHaveLength(2); + }); + + it('returns all keys when homeCarouselConfig is empty', () => { + const keys = {k1: makeKey('k1')}; + const result = getVisibleKeysFromKeys(keys, []); + expect(result).toHaveLength(1); + }); + + it('filters out hidden keys (show=false)', () => { + const keys = {k1: makeKey('k1'), k2: makeKey('k2')}; + const config = [ + {id: 'k1', show: false}, + {id: 'k2', show: true}, + ] as any[]; + const result = getVisibleKeysFromKeys(keys, config); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('k2'); + }); + + it('does not hide a key if show is true', () => { + const keys = {k1: makeKey('k1')}; + const config = [{id: 'k1', show: true}] as any[]; + const result = getVisibleKeysFromKeys(keys, config); + expect(result).toHaveLength(1); + }); + + it('ignores coinbaseBalanceCard id in carousel config', () => { + const keys = {k1: makeKey('k1')}; + const config = [ + {id: 'coinbaseBalanceCard', show: false}, + {id: 'k1', show: true}, + ] as any[]; + const result = getVisibleKeysFromKeys(keys, config); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('k1'); + }); + + it('returns all keys when no keys are hidden in config', () => { + const keys = {k1: makeKey('k1'), k2: makeKey('k2')}; + const config = [ + {id: 'k1', show: true}, + {id: 'k2', show: true}, + ] as any[]; + const result = getVisibleKeysFromKeys(keys, config); + expect(result).toHaveLength(2); + }); +}); + +// ─── getVisibleWalletsFromKeys ──────────────────────────────────────────────── + +describe('getVisibleWalletsFromKeys', () => { + const makeKeyWithWallets = (id: string, wallets: any[]) => + ({id, wallets} as any); + + it('returns empty array when keys is undefined', () => { + expect(getVisibleWalletsFromKeys(undefined)).toEqual([]); + }); + + it('filters out wallets with hideWallet=true', () => { + const wallet1 = {id: 'w1', hideWallet: false, hideWalletByAccount: false}; + const wallet2 = {id: 'w2', hideWallet: true, hideWalletByAccount: false}; + const keys = {k1: makeKeyWithWallets('k1', [wallet1, wallet2])}; + const result = getVisibleWalletsFromKeys(keys as any); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('w1'); + }); + + it('filters out wallets with hideWalletByAccount=true', () => { + const wallet1 = {id: 'w1', hideWallet: false, hideWalletByAccount: false}; + const wallet2 = {id: 'w2', hideWallet: false, hideWalletByAccount: true}; + const keys = {k1: makeKeyWithWallets('k1', [wallet1, wallet2])}; + const result = getVisibleWalletsFromKeys(keys as any); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('w1'); + }); + + it('returns all visible wallets across multiple keys', () => { + const w1 = {id: 'w1', hideWallet: false, hideWalletByAccount: false}; + const w2 = {id: 'w2', hideWallet: false, hideWalletByAccount: false}; + const keys = { + k1: makeKeyWithWallets('k1', [w1]), + k2: makeKeyWithWallets('k2', [w2]), + }; + const result = getVisibleWalletsFromKeys(keys as any); + expect(result).toHaveLength(2); + }); + + it('handles keys with undefined wallets gracefully', () => { + const keys = {k1: {id: 'k1', wallets: undefined} as any}; + const result = getVisibleWalletsFromKeys(keys); + expect(result).toEqual([]); + }); +}); + +// ─── isFiatLoadingForWallets ────────────────────────────────────────────────── + +describe('isFiatLoadingForWallets', () => { + const makeFullWallet = (id: string, network = 'livenet') => + ({id, network} as any); + + it('returns false when quoteCurrency is empty string', () => { + expect( + isFiatLoadingForWallets({ + quoteCurrency: '', + wallets: [makeFullWallet('w1')], + snapshotsByWalletId: {}, + }), + ).toBe(false); + }); + + it('returns false when wallets is empty', () => { + expect( + isFiatLoadingForWallets({ + quoteCurrency: 'USD', + wallets: [], + snapshotsByWalletId: {}, + }), + ).toBe(false); + }); + + it('returns false for testnet wallets', () => { + expect( + isFiatLoadingForWallets({ + quoteCurrency: 'USD', + wallets: [makeFullWallet('w1', 'testnet')], + snapshotsByWalletId: { + w1: [{timestamp: 1, quoteCurrency: 'EUR'} as any], + }, + }), + ).toBe(false); + }); + + it('returns false when latest snapshot quoteCurrency matches target', () => { + expect( + isFiatLoadingForWallets({ + quoteCurrency: 'USD', + wallets: [makeFullWallet('w1')], + snapshotsByWalletId: { + w1: [{timestamp: 1, quoteCurrency: 'USD'} as any], + }, + }), + ).toBe(false); + }); + + it('returns false when latest snapshot has no quoteCurrency', () => { + expect( + isFiatLoadingForWallets({ + quoteCurrency: 'USD', + wallets: [makeFullWallet('w1')], + snapshotsByWalletId: {w1: [{timestamp: 1} as any]}, + }), + ).toBe(false); + }); + + it('returns true when quoteCurrency differs and rate series not available', () => { + // hasValidSeriesForCoin is mocked to return false → missing series → loading + expect( + isFiatLoadingForWallets({ + quoteCurrency: 'EUR', + wallets: [makeFullWallet('w1')], + snapshotsByWalletId: { + w1: [{timestamp: 1, quoteCurrency: 'USD'} as any], + }, + fiatRateSeriesCache: {}, + }), + ).toBe(true); + }); + + it('uses the snapshot with the highest timestamp as "latest"', () => { + // Both snapshots have quoteCurrency 'USD' — latest (ts=2) matches target 'USD' → false + expect( + isFiatLoadingForWallets({ + quoteCurrency: 'USD', + wallets: [makeFullWallet('w1')], + snapshotsByWalletId: { + w1: [ + {timestamp: 1, quoteCurrency: 'EUR'} as any, // older + {timestamp: 2, quoteCurrency: 'USD'} as any, // latest + ], + }, + }), + ).toBe(false); + }); +}); + +// ─── getWalletLiveAtomicBalance ─────────────────────────────────────────────── + +describe('getWalletLiveAtomicBalance', () => { + it('returns 0n when no balance info present', () => { + const wallet = {chain: 'btc', balance: {}} as any; + expect(getWalletLiveAtomicBalance({wallet, unitDecimals: 8})).toBe(0n); + }); + + it('returns sat-based balance when sat is a valid integer', () => { + const wallet = {chain: 'btc', balance: {sat: 100_000_000}} as any; + expect(getWalletLiveAtomicBalance({wallet, unitDecimals: 8})).toBe( + 100_000_000n, + ); + }); + + it('falls back to crypto-string balance when sat is 0', () => { + const wallet = { + chain: 'btc', + balance: {sat: 0, crypto: '1.5'}, + } as any; + // sat=0 → satWithPendingAtomic=0 → fallback to crypto + const result = getWalletLiveAtomicBalance({wallet, unitDecimals: 8}); + // unitStringToAtomicBigInt('1.5', 8) = 150_000_000 + expect(result).toBe(150_000_000n); + }); + + it('includes satConfirmedLocked for XRP wallets', () => { + const wallet = { + chain: 'xrp', + balance: {sat: 1_000_000, satConfirmedLocked: 200_000}, + } as any; + expect(getWalletLiveAtomicBalance({wallet, unitDecimals: 6})).toBe( + 1_200_000n, + ); + }); + + it('does NOT include satConfirmedLocked for BTC wallets', () => { + const wallet = { + chain: 'btc', + balance: {sat: 1_000_000, satConfirmedLocked: 200_000}, + } as any; + expect(getWalletLiveAtomicBalance({wallet, unitDecimals: 8})).toBe( + 1_000_000n, + ); + }); + + it('falls back to crypto balance when sat is non-integer (float)', () => { + const wallet = { + chain: 'btc', + balance: {sat: 1.5, crypto: '0.01'}, // non-integer sat → falls through + } as any; + // unitStringToAtomicBigInt('0.01', 8) = 1_000_000 + expect(getWalletLiveAtomicBalance({wallet, unitDecimals: 8})).toBe( + 1_000_000n, + ); + }); + + it('falls back to crypto balance when sat is not a number', () => { + const wallet = { + chain: 'btc', + balance: {sat: 'abc', crypto: '0.5'}, + } as any; + expect(getWalletLiveAtomicBalance({wallet, unitDecimals: 8})).toBe( + 50_000_000n, + ); + }); + + it('includes satPending for UTXO chains when satConfirmed matches sat', () => { + // BTC is a UTXO coin (in the mock BitpaySupportedUtxoCoins) + const wallet = { + chain: 'btc', + balance: { + sat: 100_000_000, + satConfirmed: 100_000_000, + satPending: 50_000_000, + }, + } as any; + // shouldTreatPendingAsAvailable: btc is utxo, satPending > 0, satConfirmed >= 0, satAtomicBase === BigInt(satConfirmed) + expect(getWalletLiveAtomicBalance({wallet, unitDecimals: 8})).toBe( + 150_000_000n, + ); + }); + + it('does NOT include satPending when satConfirmed does not match sat', () => { + const wallet = { + chain: 'btc', + balance: { + sat: 100_000_000, + satConfirmed: 90_000_000, // differs from sat + satPending: 50_000_000, + }, + } as any; + expect(getWalletLiveAtomicBalance({wallet, unitDecimals: 8})).toBe( + 100_000_000n, + ); + }); +}); + +// ─── walletHasNonZeroLiveBalance ────────────────────────────────────────────── + +describe('walletHasNonZeroLiveBalance', () => { + it('returns false for a wallet with zero balance', () => { + const wallet = {chain: 'btc', balance: {sat: 0, crypto: '0'}} as any; + expect(walletHasNonZeroLiveBalance(wallet)).toBe(false); + }); + + it('returns true for a wallet with non-zero sat balance', () => { + const wallet = {chain: 'btc', balance: {sat: 1_000}} as any; + expect(walletHasNonZeroLiveBalance(wallet)).toBe(true); + }); + + it('returns true for an ETH wallet with crypto balance', () => { + const wallet = {chain: 'eth', balance: {sat: 0, crypto: '0.5'}} as any; + expect(walletHasNonZeroLiveBalance(wallet)).toBe(true); + }); +}); + +// ─── getSnapshotAtomicBalanceFromCryptoBalance ──────────────────────────────── + +describe('getSnapshotAtomicBalanceFromCryptoBalance', () => { + it('returns 0n when snapshot is undefined', () => { + expect( + getSnapshotAtomicBalanceFromCryptoBalance({ + snapshot: undefined, + unitDecimals: 8, + }), + ).toBe(0n); + }); + + it('returns 0n when cryptoBalance is missing', () => { + expect( + getSnapshotAtomicBalanceFromCryptoBalance({ + snapshot: {} as any, + unitDecimals: 8, + }), + ).toBe(0n); + }); + + it('converts cryptoBalance string to atomic bigint', () => { + const snapshot = {cryptoBalance: '1.5'} as any; + // unitStringToAtomicBigInt('1.5', 8) = 150_000_000 + expect( + getSnapshotAtomicBalanceFromCryptoBalance({snapshot, unitDecimals: 8}), + ).toBe(150_000_000n); + }); + + it('strips commas from cryptoBalance before converting', () => { + const snapshot = {cryptoBalance: '1,234'} as any; + // unitStringToAtomicBigInt('1234', 8) = 123_400_000_000 + expect( + getSnapshotAtomicBalanceFromCryptoBalance({snapshot, unitDecimals: 8}), + ).toBe(123_400_000_000n); + }); +}); + +// ─── canNavigateToExchangeRateForAssetRowItem ───────────────────────────────── + +describe('canNavigateToExchangeRateForAssetRowItem', () => { + const makeSupportedOption = ( + currencyAbbreviation: string, + chain: string, + tokenAddress?: string, + ) => ({ + currencyAbbreviation, + chain, + tokenAddress, + name: currencyAbbreviation, + img: '', + badgeUri: undefined, + }); + + it('returns false when no matching supported option is found', () => { + const item = makeItem('btc', true); + expect(canNavigateToExchangeRateForAssetRowItem({item, options: []})).toBe( + false, + ); + }); + + it('returns false when item does not have rate', () => { + const item = makeItem('btc', false); + const options = [makeSupportedOption('btc', 'btc')]; + expect(canNavigateToExchangeRateForAssetRowItem({item, options})).toBe( + false, + ); + }); + + it('returns true when item has rate and exact matching option exists', () => { + const item = makeItem('btc', true, { + currencyAbbreviation: 'btc', + chain: 'btc', + }); + const options = [makeSupportedOption('btc', 'btc')]; + expect(canNavigateToExchangeRateForAssetRowItem({item, options})).toBe( + true, + ); + }); + + it('returns false when option matches abbreviation but not chain', () => { + const item = makeItem('usdc-btc', true, { + currencyAbbreviation: 'usdc', + chain: 'btc', + }); + const options = [makeSupportedOption('usdc', 'eth')]; // different chain + expect(canNavigateToExchangeRateForAssetRowItem({item, options})).toBe( + false, + ); + }); +}); + +// ─── getPopulateLoadingByAssetKey ───────────────────────────────────────────── + +describe('getPopulateLoadingByAssetKey', () => { + it('returns undefined when populateStatus.inProgress is false', () => { + expect( + getPopulateLoadingByAssetKey({ + items: [{key: 'btc'}], + walletIdsByAssetKey: {btc: ['w1']}, + populateStatus: { + inProgress: false, + walletStatusById: {}, + walletsTotal: 0, + } as any, + }), + ).toBeUndefined(); + }); + + it('returns map with false for assets whose wallets are all done', () => { + const result = getPopulateLoadingByAssetKey({ + items: [{key: 'btc'}], + walletIdsByAssetKey: {btc: ['w1']}, + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'done'}, + walletsTotal: 1, + currentWalletId: undefined, + } as any, + }); + expect(result).not.toBeUndefined(); + expect(result!['btc']).toBe(false); + }); + + it('returns map with true for assets with in_progress wallets', () => { + const result = getPopulateLoadingByAssetKey({ + items: [{key: 'btc'}], + walletIdsByAssetKey: {btc: ['w1']}, + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'in_progress'}, + walletsTotal: 1, + currentWalletId: undefined, + } as any, + }); + expect(result!['btc']).toBe(true); + }); + + it('uses prev value when no wallets are in scope for an asset key', () => { + const result = getPopulateLoadingByAssetKey({ + items: [{key: 'eth'}], + walletIdsByAssetKey: {eth: ['w2']}, + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'in_progress'}, // w2 not in scope + walletsTotal: 1, + currentWalletId: undefined, + } as any, + prev: {eth: true}, + }); + // w2 is not in scope (not in statusById, not currentWalletId) → use prev + expect(result!['eth']).toBe(true); + }); + + it('preserves prev keys that are not in items', () => { + const result = getPopulateLoadingByAssetKey({ + items: [{key: 'btc'}], + walletIdsByAssetKey: {btc: ['w1']}, + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'done'}, + walletsTotal: 1, + currentWalletId: undefined, + } as any, + prev: {eth: true, btc: false}, + }); + // eth was in prev but not in items → it should be copied over + expect(result!['eth']).toBe(true); + expect(result!['btc']).toBe(false); + }); + + it('returns false for asset with error status (counts as finished)', () => { + const result = getPopulateLoadingByAssetKey({ + items: [{key: 'btc'}], + walletIdsByAssetKey: {btc: ['w1']}, + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'error'}, + walletsTotal: 1, + currentWalletId: undefined, + } as any, + }); + expect(result!['btc']).toBe(false); + }); + + it('returns true for asset in fullPopulate mode (wallets total matches)', () => { + // isFullPopulate=true: walletsTotal === mainnetWalletsTotal (1) + // in fullPopulate mode all wallets in walletIdsByAssetKey are used regardless of scope + const result = getPopulateLoadingByAssetKey({ + items: [{key: 'btc'}], + walletIdsByAssetKey: {btc: ['w1']}, + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'in_progress'}, + walletsTotal: 1, + currentWalletId: undefined, + } as any, + }); + expect(result!['btc']).toBe(true); + }); + + it('returns prev reference when next is identical to prev (same keys and values)', () => { + const prev = {btc: false}; + const result = getPopulateLoadingByAssetKey({ + items: [{key: 'btc'}], + walletIdsByAssetKey: {btc: ['w1']}, + populateStatus: { + inProgress: true, + walletStatusById: {w1: 'done'}, + walletsTotal: 1, + currentWalletId: undefined, + } as any, + prev, + }); + // next['btc'] = false (done → allFinished → !allFinished = false), same as prev + expect(result).toBe(prev); + }); + + it('includes currentWalletId in scope when filtering (non-fullPopulate)', () => { + // walletsTotal=99 !== mainnetWalletsTotal=1 → NOT fullPopulate + // w1 is currentWalletId so it's in scope + const result = getPopulateLoadingByAssetKey({ + items: [{key: 'btc'}], + walletIdsByAssetKey: {btc: ['w1']}, + populateStatus: { + inProgress: true, + walletStatusById: {}, + walletsTotal: 99, + currentWalletId: 'w1', + } as any, + }); + // w1 is currentWalletId but not in statusById → statusById[w1] = undefined + // undefined !== 'done' && undefined !== 'error' → allFinished=false → loading=true + expect(result!['btc']).toBe(true); + }); +}); + +// ─── findSupportedCurrencyOptionForAsset ────────────────────────────────────── + +describe('findSupportedCurrencyOptionForAsset', () => { + const makeOption = ( + currencyAbbreviation: string, + chain: string, + tokenAddress?: string, + ) => ({ + currencyAbbreviation, + chain, + tokenAddress, + name: currencyAbbreviation, + img: '', + }); + + it('returns undefined when options list is empty', () => { + expect( + findSupportedCurrencyOptionForAsset({ + options: [], + currencyAbbreviation: 'btc', + chain: 'btc', + }), + ).toBeUndefined(); + }); + + it('returns the matching option by abbreviation and chain', () => { + const opt = makeOption('btc', 'btc'); + const result = findSupportedCurrencyOptionForAsset({ + options: [opt] as any, + currencyAbbreviation: 'btc', + chain: 'btc', + }); + expect(result).toBe(opt); + }); + + it('returns undefined when abbreviation does not match', () => { + const opt = makeOption('eth', 'eth'); + expect( + findSupportedCurrencyOptionForAsset({ + options: [opt] as any, + currencyAbbreviation: 'btc', + chain: 'btc', + }), + ).toBeUndefined(); + }); + + it('matches by tokenAddress when provided', () => { + const tokenAddress = '0xabcdef1234'; + const opt = makeOption('usdc', 'eth', tokenAddress); + const result = findSupportedCurrencyOptionForAsset({ + options: [opt] as any, + currencyAbbreviation: 'usdc', + chain: 'eth', + tokenAddress, + }); + expect(result).toBe(opt); + }); + + it('returns cached result on second call with same options reference (WeakMap cache)', () => { + const opt = makeOption('btc', 'btc'); + const optionsRef = [opt] as any; + // First call populates cache + const result1 = findSupportedCurrencyOptionForAsset({ + options: optionsRef, + currencyAbbreviation: 'btc', + chain: 'btc', + }); + // Second call should hit WeakMap cache + const result2 = findSupportedCurrencyOptionForAsset({ + options: optionsRef, + currencyAbbreviation: 'btc', + chain: 'btc', + }); + expect(result1).toBe(opt); + expect(result2).toBe(opt); + }); + + it('matches case-insensitively', () => { + const opt = makeOption('BTC', 'BTC'); + const result = findSupportedCurrencyOptionForAsset({ + options: [opt] as any, + currencyAbbreviation: 'btc', + chain: 'btc', + }); + expect(result).toBe(opt); + }); +}); + +// ─── isFiatLoadingForWallets (additional branch coverage) ──────────────────── + +describe('isFiatLoadingForWallets — null-entry in snapshot array', () => { + const makeFullWallet = (id: string, network = 'livenet') => + ({id, network} as any); + + it('skips null entries in snapshot array when finding latest', () => { + // Snapshot array contains a null-ish entry (null is not BalanceSnapshot but tests the null guard) + // null is skipped, the real snapshot matches target → no loading + expect( + isFiatLoadingForWallets({ + quoteCurrency: 'USD', + wallets: [makeFullWallet('w1')], + snapshotsByWalletId: { + w1: [null as any, {timestamp: 5, quoteCurrency: 'USD'} as any], + }, + }), + ).toBe(false); + }); +}); + +// ─── getWalletIdsToPopulateFromSnapshots ────────────────────────────────────── + +describe('getWalletIdsToPopulateFromSnapshots', () => { + const makeMainnetWallet = (id: string, balanceSat = 0, chain = 'btc') => + ({ + id, + network: 'livenet', + chain, + currencyAbbreviation: chain, + balance: {sat: balanceSat, crypto: balanceSat > 0 ? '1' : '0'}, + } as any); + + it('returns empty lists for no wallets', () => { + const result = getWalletIdsToPopulateFromSnapshots({ + wallets: [], + snapshotsByWalletId: {}, + }); + expect(result.walletIdsToPopulate).toEqual([]); + expect(result.snapshotBalanceMismatchUpdates).toEqual({}); + }); + + it('skips non-mainnet wallets', () => { + const testnetWallet = { + id: 'w1', + network: 'testnet', + chain: 'btc', + currencyAbbreviation: 'btc', + balance: {sat: 1_000_000}, + } as any; + const result = getWalletIdsToPopulateFromSnapshots({ + wallets: [testnetWallet], + snapshotsByWalletId: {}, + }); + expect(result.walletIdsToPopulate).toEqual([]); + }); + + it('adds wallet to populate list when it has non-zero balance but no snapshots', () => { + const wallet = makeMainnetWallet('w1', 1_000_000); + const result = getWalletIdsToPopulateFromSnapshots({ + wallets: [wallet], + snapshotsByWalletId: {}, + }); + expect(result.walletIdsToPopulate).toContain('w1'); + }); + + it('does NOT add wallet when balance is zero and no snapshots', () => { + const wallet = makeMainnetWallet('w1', 0); + const result = getWalletIdsToPopulateFromSnapshots({ + wallets: [wallet], + snapshotsByWalletId: {}, + }); + expect(result.walletIdsToPopulate).not.toContain('w1'); + }); + + it('does NOT add wallet when live balance matches snapshot balance', () => { + // sat=0 → falls back to crypto='0' → atomic=0 + // snapshot cryptoBalance='0' → atomic=0 + // 0 === 0 → no mismatch + const wallet = makeMainnetWallet('w1', 0, 'btc'); + const result = getWalletIdsToPopulateFromSnapshots({ + wallets: [wallet], + snapshotsByWalletId: { + w1: [{cryptoBalance: '0', timestamp: 1} as any], + }, + }); + expect(result.walletIdsToPopulate).not.toContain('w1'); + expect(result.snapshotBalanceMismatchUpdates).toEqual({}); + }); + + it('adds wallet when live balance differs from snapshot balance (mismatch)', () => { + // sat=100_000_000 → live atomic = 100_000_000n (1 BTC) + // snapshot cryptoBalance='0.5' → atomic = 50_000_000n + // mismatch detected → populate + const wallet = makeMainnetWallet('w1', 100_000_000, 'btc'); + const result = getWalletIdsToPopulateFromSnapshots({ + wallets: [wallet], + snapshotsByWalletId: { + w1: [{cryptoBalance: '0.5', timestamp: 1} as any], + }, + }); + expect(result.walletIdsToPopulate).toContain('w1'); + expect(result.snapshotBalanceMismatchUpdates['w1']).toBeDefined(); + expect(result.snapshotBalanceMismatchUpdates['w1']?.walletId).toBe('w1'); + }); + + it('does not add to populate list when mismatch unchanged from previous', () => { + // Same mismatch as before (prevMismatch equals new mismatch) → no push to changed list + // but walletIdsToPopulate won't include it (already tracked) + const wallet = makeMainnetWallet('w1', 100_000_000, 'btc'); + const previousMismatch = { + walletId: 'w1', + computedUnitsHeld: '0', // atomicToUnitString is mocked to return '0' + currentWalletBalance: '0', + delta: '0', + }; + const result = getWalletIdsToPopulateFromSnapshots({ + wallets: [wallet], + snapshotsByWalletId: { + w1: [{cryptoBalance: '0.5', timestamp: 1} as any], + }, + previousSnapshotBalanceMismatchesByWalletId: {w1: previousMismatch}, + }); + // mismatch equals prevMismatch (all fields match because atomicToUnitString is mocked to '0') + expect(result.walletIdsToPopulate).not.toContain('w1'); + }); + + it('clears mismatch entry when live balance now matches snapshot', () => { + // sat=0, crypto='0' → live=0n; snapshot='0' → snap=0n; same → no mismatch + // but prevMismatch existed → sets it to undefined in updates + const wallet = makeMainnetWallet('w1', 0, 'btc'); + const previousMismatch = { + walletId: 'w1', + computedUnitsHeld: '0', + currentWalletBalance: '0', + delta: '0', + }; + const result = getWalletIdsToPopulateFromSnapshots({ + wallets: [wallet], + snapshotsByWalletId: { + w1: [{cryptoBalance: '0', timestamp: 1} as any], + }, + previousSnapshotBalanceMismatchesByWalletId: {w1: previousMismatch}, + }); + expect(result.walletIdsToPopulate).not.toContain('w1'); + expect('w1' in result.snapshotBalanceMismatchUpdates).toBe(true); + expect(result.snapshotBalanceMismatchUpdates['w1']).toBeUndefined(); + }); + + it('skips wallet with no id', () => { + const wallet = { + network: 'livenet', + chain: 'btc', + currencyAbbreviation: 'btc', + balance: {sat: 1_000_000}, + } as any; + const result = getWalletIdsToPopulateFromSnapshots({ + wallets: [wallet], + snapshotsByWalletId: {}, + }); + expect(result.walletIdsToPopulate).toEqual([]); + }); +}); + +// ─── getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots ────────────────── + +describe('getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots', () => { + const makeSnap = (timestamp: number, cryptoBalance = '1') => + ({timestamp, cryptoBalance, quoteCurrency: 'USD', eventType: 'tx'} as any); + + const makeWallet = (id: string) => + ({ + id, + network: 'livenet', + chain: 'btc', + currencyAbbreviation: 'btc', + balance: {sat: 100_000_000}, + } as any); + + it('returns unavailable result when fiatRateSeriesCache is missing', () => { + const result = getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots({ + snapshotsByWalletId: {w1: [makeSnap(1000)]}, + wallets: [makeWallet('w1')], + quoteCurrency: 'USD', + timeframe: '1D', + nowMs: 2000, + // no fiatRateSeriesCache + }); + expect(result.available).toBe(false); + expect(result.error).toMatch(/fiatRateSeriesCache/i); + expect(result.timeframe).toBe('1D'); + expect(result.quoteCurrency).toBe('USD'); + }); + + it('returns available zero result when no mainnet wallets with snapshots', () => { + const result = getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots({ + snapshotsByWalletId: {}, + wallets: [], + quoteCurrency: 'USD', + timeframe: '1D', + fiatRateSeriesCache: {} as any, + nowMs: 2000, + }); + expect(result.available).toBe(true); + expect(result.deltaFiat).toBe(0); + expect(result.percentRatio).toBe(0); + }); + + it('returns unavailable when buildPnlAnalysisSeries returns no points', () => { + // buildPnlAnalysisSeries mock returns {points: []} — no last point → unavailable + const result = getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots({ + snapshotsByWalletId: {w1: [makeSnap(1000)]}, + wallets: [makeWallet('w1')], + quoteCurrency: 'USD', + timeframe: '1D', + fiatRateSeriesCache: {} as any, + nowMs: 2000, + }); + expect(result.available).toBe(false); + expect(result.error).toMatch(/no points/i); + }); + + it('skips testnet wallets', () => { + const testnetWallet = { + id: 'w1', + network: 'testnet', + chain: 'btc', + currencyAbbreviation: 'btc', + balance: {sat: 100_000_000}, + } as any; + const result = getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots({ + snapshotsByWalletId: {w1: [makeSnap(1000)]}, + wallets: [testnetWallet], + quoteCurrency: 'USD', + timeframe: '1D', + fiatRateSeriesCache: {} as any, + nowMs: 2000, + }); + // testnet wallet skipped → no pnlWallets → available:true zeroResult + expect(result.available).toBe(true); + expect(result.deltaFiat).toBe(0); + }); + + it('uses effective quoteCurrency from snapshots when portfolioQuoteCurrency is empty', () => { + const result = getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots({ + snapshotsByWalletId: {}, + wallets: [], + quoteCurrency: '', + timeframe: '1D', + fiatRateSeriesCache: {} as any, + nowMs: 2000, + }); + // no snaps → getEffectiveQuoteCurrencyFromSnapshots returns 'USD' as fallback + expect(result.quoteCurrency).toBe('USD'); + }); + + it('uses provided nowMs for baselineTimestampMs', () => { + const nowMs = 9_999_999; + const result = getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots({ + snapshotsByWalletId: {}, + wallets: [], + quoteCurrency: 'USD', + timeframe: '1D', + fiatRateSeriesCache: {} as any, + nowMs, + }); + // getFiatRateBaselineTsForTimeframe is mocked to return undefined → falls back to nowMs + expect(result.baselineTimestampMs).toBe(nowMs); + }); +}); + +// ─── buildPortfolioGainLossSummaryFromPortfolioSnapshots ────────────────────── + +describe('buildPortfolioGainLossSummaryFromPortfolioSnapshots', () => { + it('returns summary with today and total from two timeframes', () => { + const result = buildPortfolioGainLossSummaryFromPortfolioSnapshots({ + snapshotsByWalletId: {}, + wallets: [], + quoteCurrency: 'USD', + fiatRateSeriesCache: {} as any, + nowMs: 1000, + }); + expect(result).toHaveProperty('quoteCurrency'); + expect(result).toHaveProperty('today'); + expect(result).toHaveProperty('total'); + expect(result.today).toHaveProperty('available'); + expect(result.total).toHaveProperty('available'); + }); + + it('returns provided quoteCurrency when preferred currency is given', () => { + const result = buildPortfolioGainLossSummaryFromPortfolioSnapshots({ + snapshotsByWalletId: {}, + wallets: [], + quoteCurrency: 'EUR', + fiatRateSeriesCache: {} as any, + nowMs: 1000, + }); + // 'EUR' is set as preferredQuoteCurrency → effectiveQuoteCurrency = 'EUR' + expect(result.quoteCurrency).toBe('EUR'); + }); + + it('includes error fields when PnL engine returns unavailable', () => { + // No fiatRateSeriesCache → both today and total are unavailable + const result = buildPortfolioGainLossSummaryFromPortfolioSnapshots({ + snapshotsByWalletId: {}, + wallets: [], + quoteCurrency: 'USD', + nowMs: 1000, + }); + // missing fiatRateSeriesCache → available=false with error + expect(result.today.available).toBe(false); + expect(result.total.available).toBe(false); + }); +}); + +// ─── buildAssetRowItemsFromPortfolioSnapshots ───────────────────────────────── + +describe('buildAssetRowItemsFromPortfolioSnapshots', () => { + const makeSnap = (timestamp: number, cryptoBalance = '1') => + ({timestamp, cryptoBalance, quoteCurrency: 'USD', eventType: 'tx'} as any); + + const makeWalletFull = ( + id: string, + coin = 'btc', + chain = 'btc', + balanceSat = 100_000_000, + network = 'livenet', + ) => + ({ + id, + network, + chain, + currencyAbbreviation: coin, + balance: {sat: balanceSat}, + } as any); + + it('returns empty array when wallets list is empty', () => { + expect( + buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: {}, + wallets: [], + quoteCurrency: 'USD', + gainLossMode: '1D', + }), + ).toEqual([]); + }); + + it('returns empty array when all wallets are testnet', () => { + const wallet = makeWalletFull('w1', 'btc', 'btc', 100_000_000, 'testnet'); + expect( + buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: {w1: [makeSnap(1000)]}, + wallets: [wallet], + quoteCurrency: 'USD', + gainLossMode: '1D', + }), + ).toEqual([]); + }); + + it('returns empty array when all wallets have zero live balance', () => { + const wallet = makeWalletFull('w1', 'btc', 'btc', 0); + expect( + buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: {w1: [makeSnap(1000)]}, + wallets: [wallet], + quoteCurrency: 'USD', + gainLossMode: '1D', + }), + ).toEqual([]); + }); + + it('returns one row for a single wallet with non-zero balance', () => { + const wallet = makeWalletFull('w1', 'btc', 'btc', 100_000_000); + const rows = buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: {w1: [makeSnap(1000)]}, + wallets: [wallet], + quoteCurrency: 'USD', + gainLossMode: '1D', + }); + expect(rows).toHaveLength(1); + expect(rows[0].currencyAbbreviation).toBe('btc'); + expect(rows[0].chain).toBe('btc'); + expect(rows[0].hasRate).toBe(false); // no rates provided + expect(rows[0].hasPnl).toBe(false); + expect(rows[0].showPnlPlaceholder).toBe(true); + }); + + it('groups wallets by coin when collapseAcrossChains is true', () => { + const wallet1 = makeWalletFull( + 'w1', + 'eth', + 'eth', + 1_000_000_000_000_000_000n as any, + ); + const wallet2 = makeWalletFull( + 'w2', + 'eth', + 'base', + 1_000_000_000_000_000_000n as any, + ); + const rows = buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: { + w1: [makeSnap(1000)], + w2: [makeSnap(1000)], + }, + wallets: [wallet1, wallet2], + quoteCurrency: 'USD', + gainLossMode: '1D', + collapseAcrossChains: true, + }); + // Both collapse to key='eth' + expect(rows.length).toBeLessThanOrEqual(1); + }); + + it('does not group wallets by different coins when collapseAcrossChains is false', () => { + const btcWallet = makeWalletFull('w1', 'btc', 'btc', 100_000_000); + const ethWallet = makeWalletFull('w2', 'eth', 'eth', 100_000_000); + const rows = buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: { + w1: [makeSnap(1000)], + w2: [makeSnap(1000)], + }, + wallets: [btcWallet, ethWallet], + quoteCurrency: 'USD', + gainLossMode: '1D', + collapseAcrossChains: false, + }); + expect(rows).toHaveLength(2); + }); + + it('returns rows sorted by fiatValue descending (all zero when no rates)', () => { + const btcWallet = makeWalletFull('w1', 'btc', 'btc', 100_000_000); + const ethWallet = makeWalletFull('w2', 'eth', 'eth', 200_000_000); + const rows = buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: { + w1: [makeSnap(1000)], + w2: [makeSnap(1000)], + }, + wallets: [btcWallet, ethWallet], + quoteCurrency: 'USD', + gainLossMode: '1D', + }); + // Both fiatValue=0 (no rates) — order is stable but both present + expect(rows).toHaveLength(2); + }); + + it('sets showPnlPlaceholder=false when hasPnl=true', () => { + // buildPnlAnalysisSeries is mocked to return [] (no last point) → hasPnl stays false + // Even with fiatRateSeriesCache present → no points → hasPnl=false + const wallet = makeWalletFull('w1', 'btc', 'btc', 100_000_000); + const rows = buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: {w1: [makeSnap(1000)]}, + wallets: [wallet], + quoteCurrency: 'USD', + gainLossMode: '1D', + fiatRateSeriesCache: {} as any, + }); + expect(rows).toHaveLength(1); + // hasPnl false + isTodayGainLoss=true + hasRate=false → showPnlPlaceholder=true + expect(rows[0].showPnlPlaceholder).toBe(true); + }); + + it('row has isPositive=true when pnlFiat is zero', () => { + const wallet = makeWalletFull('w1', 'btc', 'btc', 100_000_000); + const rows = buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: {w1: [makeSnap(1000)]}, + wallets: [wallet], + quoteCurrency: 'USD', + gainLossMode: 'ALL', + }); + expect(rows[0].isPositive).toBe(true); // pnlFiat=0 → 0 >= 0 + }); + + it('handles ALL timeframe (not isTodayGainLoss)', () => { + const wallet = makeWalletFull('w1', 'btc', 'btc', 100_000_000); + const rows = buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: {w1: [makeSnap(1000)]}, + wallets: [wallet], + quoteCurrency: 'USD', + gainLossMode: 'ALL', + }); + // showPnlPlaceholder = !hasPnl && (!isTodayGainLoss || !hasRate) + // hasPnl=false, isTodayGainLoss=false → !false=true → placeholder=true + expect(rows[0].showPnlPlaceholder).toBe(true); + }); + + it('uses out-of-order snapshots by sorting them (ensureSortedSnapshots branch)', () => { + const wallet = makeWalletFull('w1', 'btc', 'btc', 100_000_000); + // Provide out-of-order snapshots — second timestamp less than first + const rows = buildAssetRowItemsFromPortfolioSnapshots({ + snapshotsByWalletId: { + w1: [ + { + timestamp: 2000, + cryptoBalance: '0.5', + quoteCurrency: 'USD', + eventType: 'tx', + } as any, + { + timestamp: 500, + cryptoBalance: '0.3', + quoteCurrency: 'USD', + eventType: 'tx', + } as any, + ], + }, + wallets: [wallet], + quoteCurrency: 'USD', + gainLossMode: '1D', + }); + // Still produces a row (sorted internally) + expect(rows).toHaveLength(1); + }); +}); diff --git a/src/utils/portfolio/core/fiatRateSeries.spec.ts b/src/utils/portfolio/core/fiatRateSeries.spec.ts new file mode 100644 index 0000000000..953042b254 --- /dev/null +++ b/src/utils/portfolio/core/fiatRateSeries.spec.ts @@ -0,0 +1,243 @@ +/** + * Tests for src/utils/portfolio/core/fiatRateSeries.ts + */ +import { + getFiatRateSeriesCacheKey, + upsertFiatRateSeriesCache, +} from './fiatRateSeries'; +import type {FiatRateSeries, FiatRateSeriesCache} from './fiatRateSeries'; + +// ─── getFiatRateSeriesCacheKey ──────────────────────────────────────────────── + +describe('getFiatRateSeriesCacheKey', () => { + it('returns a colon-separated key with uppercase fiatCode and lowercase coin', () => { + expect(getFiatRateSeriesCacheKey('USD', 'BTC', '1D')).toBe('USD:btc:1D'); + }); + + it('normalises fiatCode to uppercase and coin to lowercase', () => { + expect(getFiatRateSeriesCacheKey('eur', 'ETH', '1W')).toBe('EUR:eth:1W'); + }); + + it('handles empty fiatCode and coin gracefully', () => { + // fiatCode '' → '' (toUpperCase), coin '' → '' (toLowerCase), interval '1M' + expect(getFiatRateSeriesCacheKey('', '', '1M')).toBe(':' + ':1M'); + }); + + it('produces different keys for different intervals', () => { + const key1D = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const key1Y = getFiatRateSeriesCacheKey('USD', 'btc', '1Y'); + expect(key1D).not.toBe(key1Y); + }); + + it('produces different keys for different fiat codes', () => { + const usd = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const eur = getFiatRateSeriesCacheKey('EUR', 'btc', '1D'); + expect(usd).not.toBe(eur); + }); + + it('produces different keys for different coins', () => { + const btc = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const eth = getFiatRateSeriesCacheKey('USD', 'eth', '1D'); + expect(btc).not.toBe(eth); + }); +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeSeries(fetchedOn: number): FiatRateSeries { + return {fetchedOn, points: [{ts: fetchedOn, rate: 1.23}]}; +} + +// ─── upsertFiatRateSeriesCache ──────────────────────────────────────────────── + +describe('upsertFiatRateSeriesCache', () => { + it('returns an empty object when both current and updates are empty', () => { + const result = upsertFiatRateSeriesCache({}, {}); + expect(result).toEqual({}); + }); + + it('merges an update into an empty current cache', () => { + const key = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const series = makeSeries(1000); + + const result = upsertFiatRateSeriesCache({}, {[key]: series}); + + expect(result[key]).toEqual(series); + }); + + it('overwrites an existing entry with the same key', () => { + const key = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const oldSeries = makeSeries(1000); + const newSeries = makeSeries(2000); + + const result = upsertFiatRateSeriesCache( + {[key]: oldSeries}, + {[key]: newSeries}, + ); + + expect(result[key]).toEqual(newSeries); + }); + + // ── maxFiatsPersisted = 1 (default) ──────────────────────────────────────── + + describe('maxFiatsPersisted = 1 (default)', () => { + it('keeps only the most-recently-fetched fiat when two fiats are present', () => { + const usdKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const eurKey = getFiatRateSeriesCacheKey('EUR', 'btc', '1D'); + + const usdSeries = makeSeries(1000); // older + const eurSeries = makeSeries(2000); // more recent + + const result = upsertFiatRateSeriesCache( + {[usdKey]: usdSeries}, + {[eurKey]: eurSeries}, + ); + + // EUR was fetched most recently, so USD should be pruned + expect(result[eurKey]).toEqual(eurSeries); + expect(result[usdKey]).toBeUndefined(); + }); + + it('keeps all intervals for the winning fiat, prunes losing fiat', () => { + const usd1D = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const usd1W = getFiatRateSeriesCacheKey('USD', 'btc', '1W'); + const eurKey = getFiatRateSeriesCacheKey('EUR', 'btc', '1D'); + + const currentCache: FiatRateSeriesCache = { + [usd1D]: makeSeries(3000), + [usd1W]: makeSeries(3000), + }; + + // EUR is older than USD + const result = upsertFiatRateSeriesCache(currentCache, { + [eurKey]: makeSeries(1000), + }); + + expect(result[usd1D]).toBeDefined(); + expect(result[usd1W]).toBeDefined(); + expect(result[eurKey]).toBeUndefined(); + }); + + it('keeps the single fiat when only one fiat is in the cache', () => { + const key = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const series = makeSeries(1000); + + const result = upsertFiatRateSeriesCache({}, {[key]: series}); + + expect(result[key]).toEqual(series); + }); + }); + + // ── maxFiatsPersisted > 1 ───────────────────────────────────────────────── + + describe('maxFiatsPersisted = 2', () => { + it('keeps two most-recently-fetched fiats and prunes the oldest', () => { + const usdKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const eurKey = getFiatRateSeriesCacheKey('EUR', 'btc', '1D'); + const gbpKey = getFiatRateSeriesCacheKey('GBP', 'btc', '1D'); + + const cache: FiatRateSeriesCache = { + [usdKey]: makeSeries(3000), // most recent + [eurKey]: makeSeries(2000), // second + [gbpKey]: makeSeries(1000), // oldest — should be pruned + }; + + const result = upsertFiatRateSeriesCache({}, cache, { + maxFiatsPersisted: 2, + }); + + expect(result[usdKey]).toBeDefined(); + expect(result[eurKey]).toBeDefined(); + expect(result[gbpKey]).toBeUndefined(); + }); + + it('keeps all fiats when count does not exceed maxFiatsPersisted', () => { + const usdKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const eurKey = getFiatRateSeriesCacheKey('EUR', 'btc', '1D'); + + const cache: FiatRateSeriesCache = { + [usdKey]: makeSeries(2000), + [eurKey]: makeSeries(1000), + }; + + const result = upsertFiatRateSeriesCache({}, cache, { + maxFiatsPersisted: 3, + }); + + expect(result[usdKey]).toBeDefined(); + expect(result[eurKey]).toBeDefined(); + }); + }); + + // ── Edge cases ──────────────────────────────────────────────────────────── + + describe('edge cases', () => { + it('handles cache entries with non-number fetchedOn gracefully (skipped in ranking)', () => { + const validKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const invalidKey = 'USD:bad-series'; + + const result = upsertFiatRateSeriesCache( + { + [validKey]: makeSeries(1000), + [invalidKey]: {fetchedOn: 'not-a-number', points: []} as any, + }, + {}, + ); + + // The valid USD entry is kept; the invalid entry has no fiat code at + // index 0 (key starts with 'USD') but fetchedOn is not a number so it's + // not counted in ranking — it should still be kept because its fiat code + // matches the kept set from the valid entry. + expect(result[validKey]).toBeDefined(); + }); + + it('keeps entries whose cacheKey has no valid fiatCode (no colon)', () => { + const malformedKey = 'nocolon'; + const validKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + + const result = upsertFiatRateSeriesCache( + {[malformedKey]: makeSeries(9999)}, + {[validKey]: makeSeries(1000)}, + ); + + // Malformed keys (no fiatCode) are always kept per the pruning logic + expect(result[malformedKey]).toBeDefined(); + expect(result[validKey]).toBeDefined(); + }); + + it('does not mutate the input caches', () => { + const key = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const current: FiatRateSeriesCache = {}; + const updates: FiatRateSeriesCache = {[key]: makeSeries(1000)}; + + upsertFiatRateSeriesCache(current, updates); + + expect(current).toEqual({}); + expect(Object.keys(updates)).toHaveLength(1); + }); + + it('respects maxFiatsPersisted minimum of 1 even when 0 is passed', () => { + const usdKey = getFiatRateSeriesCacheKey('USD', 'btc', '1D'); + const eurKey = getFiatRateSeriesCacheKey('EUR', 'btc', '1D'); + + const cache: FiatRateSeriesCache = { + [usdKey]: makeSeries(2000), + [eurKey]: makeSeries(1000), + }; + + // maxFiatsPersisted: 0 → clamped to 1 + const result = upsertFiatRateSeriesCache({}, cache, { + maxFiatsPersisted: 0, + }); + + // Only one fiat should remain + const definedEntries = Object.values(result).filter(Boolean); + const definedFiatCodes = new Set( + Object.keys(result) + .filter(k => result[k] !== undefined) + .map(k => k.split(':')[0]), + ); + expect(definedFiatCodes.size).toBe(1); + }); + }); +}); diff --git a/src/utils/portfolio/core/format.spec.ts b/src/utils/portfolio/core/format.spec.ts new file mode 100644 index 0000000000..e0e385f59d --- /dev/null +++ b/src/utils/portfolio/core/format.spec.ts @@ -0,0 +1,365 @@ +/** + * Tests for src/utils/portfolio/core/format.ts + */ +import { + getAtomicDecimals, + formatAtomicAmount, + parseAtomicToBigint, + formatBigIntDecimal, + formatChainAndNetwork, + formatWalletId, + formatUnixTimeSecondsToLocal, + titleCase, +} from './format'; + +// ─── getAtomicDecimals ─────────────────────────────────────────────────────── + +describe('getAtomicDecimals', () => { + it('returns token decimals when token.decimals is a number', () => { + expect(getAtomicDecimals({token: {decimals: 6}})).toBe(6); + expect(getAtomicDecimals({token: {decimals: 0}})).toBe(0); + }); + + it('ignores token.decimals when it is not a number', () => { + // should fall through to chain matching + expect(getAtomicDecimals({token: {decimals: '6'}, chain: 'btc'})).toBe(8); + }); + + it('returns 8 for btc', () => { + expect(getAtomicDecimals({chain: 'btc'})).toBe(8); + }); + + it('returns 8 for bch', () => { + expect(getAtomicDecimals({chain: 'bch'})).toBe(8); + }); + + it('returns 8 for ltc', () => { + expect(getAtomicDecimals({chain: 'ltc'})).toBe(8); + }); + + it('returns 8 for doge', () => { + expect(getAtomicDecimals({chain: 'doge'})).toBe(8); + }); + + it('returns 18 for eth', () => { + expect(getAtomicDecimals({chain: 'eth'})).toBe(18); + }); + + it('returns 18 for matic', () => { + expect(getAtomicDecimals({chain: 'matic'})).toBe(18); + }); + + it('returns 18 for arb', () => { + expect(getAtomicDecimals({chain: 'arb'})).toBe(18); + }); + + it('returns 18 for base', () => { + expect(getAtomicDecimals({chain: 'base'})).toBe(18); + }); + + it('returns 18 for op', () => { + expect(getAtomicDecimals({chain: 'op'})).toBe(18); + }); + + it('returns 6 for xrp', () => { + expect(getAtomicDecimals({chain: 'xrp'})).toBe(6); + }); + + it('returns 9 for sol', () => { + expect(getAtomicDecimals({chain: 'sol'})).toBe(9); + }); + + it('falls back to coin when chain is absent', () => { + expect(getAtomicDecimals({coin: 'btc'})).toBe(8); + expect(getAtomicDecimals({coin: 'eth'})).toBe(18); + }); + + it('is case-insensitive for chain', () => { + expect(getAtomicDecimals({chain: 'BTC'})).toBe(8); + expect(getAtomicDecimals({chain: 'ETH'})).toBe(18); + }); + + it('returns 8 (fallback) for unknown chains', () => { + expect(getAtomicDecimals({chain: 'unknown-chain'})).toBe(8); + expect(getAtomicDecimals({})).toBe(8); + }); +}); + +// ─── parseAtomicToBigint ───────────────────────────────────────────────────── + +describe('parseAtomicToBigint', () => { + it('passes through a bigint unchanged', () => { + expect(parseAtomicToBigint(42n)).toBe(42n); + }); + + it('converts a safe integer number', () => { + expect(parseAtomicToBigint(1000000)).toBe(1000000n); + }); + + it('converts a decimal string', () => { + expect(parseAtomicToBigint('123456789')).toBe(123456789n); + }); + + it('drops fractional part from a decimal string', () => { + // The string "1.5" has a fractional part — integer portion only + expect(parseAtomicToBigint('100')).toBe(100n); + }); + + it('converts a large safe-boundary number via string path', () => { + // 1e15 is safe integer territory + expect(parseAtomicToBigint(1_000_000_000_000_000)).toBe( + 1_000_000_000_000_000n, + ); + }); + + it('handles Infinity by returning 0n', () => { + expect(parseAtomicToBigint(Infinity)).toBe(0n); + expect(parseAtomicToBigint(-Infinity)).toBe(0n); + }); + + it('handles NaN by returning 0n', () => { + expect(parseAtomicToBigint(NaN)).toBe(0n); + }); + + it('handles empty string by returning 0n', () => { + expect(parseAtomicToBigint('')).toBe(0n); + }); + + it('handles negative values', () => { + expect(parseAtomicToBigint(-500n)).toBe(-500n); + expect(parseAtomicToBigint('-500')).toBe(-500n); + }); + + it('throws for strings that cannot be parsed as integers', () => { + // 'abc' has no 'e'/'E', fails DECIMAL_NUMBER_RE, so tryParse returns null + // which causes parseAtomicToBigint to throw. + expect(() => parseAtomicToBigint('abc')).toThrow('Invalid atomic string'); + expect(() => parseAtomicToBigint('12.34.56')).toThrow( + 'Invalid atomic string', + ); + }); +}); + +// ─── formatBigIntDecimal ───────────────────────────────────────────────────── + +describe('formatBigIntDecimal', () => { + it('formats zero correctly', () => { + expect(formatBigIntDecimal(0n, 8)).toBe('0'); + }); + + it('formats a whole number (no fractional part)', () => { + // 1 BTC = 100_000_000 satoshis + expect(formatBigIntDecimal(100_000_000n, 8)).toBe('1'); + }); + + it('formats a fractional amount', () => { + // 0.5 BTC = 50_000_000 satoshis + expect(formatBigIntDecimal(50_000_000n, 8)).toBe('0.5'); + }); + + it('trims trailing zeros from fractional part', () => { + expect(formatBigIntDecimal(150_000_000n, 8)).toBe('1.5'); + }); + + it('respects maxDecimals', () => { + // 1.23456789 BTC with maxDecimals=4 → 1.2345 (trailing zero trimmed) + expect(formatBigIntDecimal(123_456_789n, 8, 4)).toBe('1.2345'); + }); + + it('handles maxDecimals=0 (whole units only)', () => { + expect(formatBigIntDecimal(123_456_789n, 8, 0)).toBe('1'); + }); + + it('handles negative atomic values', () => { + expect(formatBigIntDecimal(-100_000_000n, 8)).toBe('-1'); + expect(formatBigIntDecimal(-50_000_000n, 8)).toBe('-0.5'); + }); + + it('handles EVM 18-decimal amounts', () => { + // 1 ETH = 1e18 wei + const oneEth = BigInt('1000000000000000000'); + expect(formatBigIntDecimal(oneEth, 18)).toBe('1'); + }); + + it('handles partial ETH amounts', () => { + // 0.001 ETH = 1e15 wei + const partialEth = BigInt('1000000000000000'); + expect(formatBigIntDecimal(partialEth, 18)).toBe('0.001'); + }); + + it('returns integer string when decimals <= 0', () => { + expect(formatBigIntDecimal(42n, 0)).toBe('42'); + }); +}); + +// ─── formatAtomicAmount ────────────────────────────────────────────────────── + +describe('formatAtomicAmount', () => { + const btcCredentials = {chain: 'btc'}; + const ethCredentials = {chain: 'eth'}; + + it('formats BTC satoshis as BTC', () => { + expect(formatAtomicAmount(100_000_000, btcCredentials)).toBe('1'); + expect(formatAtomicAmount(100_000_000n, btcCredentials)).toBe('1'); + expect(formatAtomicAmount('100000000', btcCredentials)).toBe('1'); + }); + + it('formats partial BTC correctly', () => { + expect(formatAtomicAmount(50_000_000, btcCredentials)).toBe('0.5'); + }); + + it('formats ETH wei as ETH', () => { + expect(formatAtomicAmount('1000000000000000000', ethCredentials)).toBe('1'); + }); + + it('respects maxDecimals option', () => { + // 1.23456789 BTC, maxDecimals=2 + const result = formatAtomicAmount(123_456_789, btcCredentials, { + maxDecimals: 2, + }); + expect(result).toBe('1.23'); + }); + + it('handles zero atomic value', () => { + expect(formatAtomicAmount(0, btcCredentials)).toBe('0'); + expect(formatAtomicAmount(0n, btcCredentials)).toBe('0'); + expect(formatAtomicAmount('0', btcCredentials)).toBe('0'); + }); + + it('handles Infinity gracefully', () => { + expect(formatAtomicAmount(Infinity, btcCredentials)).toBe('0'); + }); + + it('handles NaN gracefully', () => { + expect(formatAtomicAmount(NaN, btcCredentials)).toBe('0'); + }); + + it('uses token decimals when present', () => { + const usdcCredentials = {chain: 'eth', token: {decimals: 6}}; + // 1 USDC = 1_000_000 atomic units + expect(formatAtomicAmount(1_000_000, usdcCredentials)).toBe('1'); + }); + + it('handles negative amounts', () => { + expect(formatAtomicAmount(-100_000_000n, btcCredentials)).toBe('-1'); + }); +}); + +// ─── formatChainAndNetwork ─────────────────────────────────────────────────── + +describe('formatChainAndNetwork', () => { + it('formats chain in UPPER and livenet → mainnet', () => { + expect(formatChainAndNetwork({chain: 'btc', network: 'livenet'})).toBe( + 'BTC/mainnet', + ); + }); + + it('formats testnet correctly', () => { + expect(formatChainAndNetwork({chain: 'eth', network: 'testnet'})).toBe( + 'ETH/testnet', + ); + }); + + it('falls back to coin when chain is missing', () => { + expect(formatChainAndNetwork({coin: 'sol', network: 'livenet'})).toBe( + 'SOL/mainnet', + ); + }); + + it('returns unknown when network is absent', () => { + expect(formatChainAndNetwork({chain: 'btc'})).toBe('BTC/unknown'); + }); + + it('handles empty credentials gracefully', () => { + expect(formatChainAndNetwork({})).toBe('/unknown'); + }); +}); + +// ─── formatWalletId ────────────────────────────────────────────────────────── + +describe('formatWalletId', () => { + it('returns the walletId unchanged when it is short enough', () => { + expect(formatWalletId('abc123')).toBe('abc123'); + expect(formatWalletId('1234567890')).toBe('1234567890'); + }); + + it('truncates long wallet IDs with ellipsis', () => { + const long = '1234567890abcdef'; + const result = formatWalletId(long); + expect(result).toMatch(/^123456…/); + expect(result).toMatch(/cdef$/); + }); + + it('respects a custom max length', () => { + const id = 'abcdefghij'; // 10 chars + expect(formatWalletId(id, 10)).toBe(id); + expect(formatWalletId(id, 9)).toMatch(/^abcdef…/); + }); + + it('the truncated form contains the first 6 and last 4 chars', () => { + const id = 'AAAAAA_BBBB_CCCC'; + const result = formatWalletId(id); + expect(result.startsWith('AAAAAA')).toBe(true); + expect(result.endsWith('CCCC')).toBe(true); + expect(result).toContain('…'); + }); +}); + +// ─── formatUnixTimeSecondsToLocal ──────────────────────────────────────────── + +describe('formatUnixTimeSecondsToLocal', () => { + it('returns empty string for undefined', () => { + expect(formatUnixTimeSecondsToLocal(undefined)).toBe(''); + }); + + it('returns empty string for 0', () => { + expect(formatUnixTimeSecondsToLocal(0)).toBe(''); + }); + + it('returns empty string for Infinity', () => { + expect(formatUnixTimeSecondsToLocal(Infinity)).toBe(''); + }); + + it('returns empty string for NaN', () => { + expect(formatUnixTimeSecondsToLocal(NaN)).toBe(''); + }); + + it('returns a non-empty string for a valid unix timestamp', () => { + // 2024-01-15T00:00:00Z → unix seconds = 1705276800 + const result = formatUnixTimeSecondsToLocal(1705276800); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); +}); + +// ─── titleCase ─────────────────────────────────────────────────────────────── + +describe('titleCase', () => { + it('capitalizes the first letter of each word', () => { + expect(titleCase('hello world')).toBe('Hello World'); + }); + + it('works on a single word', () => { + expect(titleCase('foo')).toBe('Foo'); + }); + + it('collapses multiple spaces between words', () => { + expect(titleCase('hello world')).toBe('Hello World'); + }); + + it('returns empty string for empty input', () => { + expect(titleCase('')).toBe(''); + }); + + it('handles already-capitalized words', () => { + expect(titleCase('HELLO WORLD')).toBe('HELLO WORLD'); + }); + + it('handles mixed case', () => { + expect(titleCase('the quick brown fox')).toBe('The Quick Brown Fox'); + }); + + it('handles leading/trailing whitespace by filtering blank tokens', () => { + expect(titleCase(' hello world ')).toBe('Hello World'); + }); +}); diff --git a/src/utils/portfolio/core/pnl/analysis.spec.ts b/src/utils/portfolio/core/pnl/analysis.spec.ts new file mode 100644 index 0000000000..9cdc32ec27 --- /dev/null +++ b/src/utils/portfolio/core/pnl/analysis.spec.ts @@ -0,0 +1,1219 @@ +/** + * Tests for src/utils/portfolio/core/pnl/analysis.ts + * + * Covers buildPnlAnalysisSeries — the main P&L engine — with concrete numeric + * inputs so that regressions in financial math are caught immediately. + */ +import {buildPnlAnalysisSeries} from './analysis'; +import type {WalletForAnalysis} from './analysis'; +import type {FiatRateSeriesCache} from '../fiatRateSeries'; +import {getFiatRateSeriesCacheKey} from '../fiatRateSeries'; + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const MS_PER_HOUR = 60 * 60 * 1000; + +/** Build a linear rate series from startTs to endTs with a fixed point count. */ +function makeRateSeries( + startTs: number, + endTs: number, + startRate: number, + endRate: number, + points = 100, +) { + const result: {ts: number; rate: number}[] = []; + for (let i = 0; i < points; i++) { + const t = startTs + ((endTs - startTs) * i) / (points - 1); + const r = startRate + ((endRate - startRate) * i) / (points - 1); + result.push({ts: Math.round(t), rate: r}); + } + return result; +} + +/** Build a flat (constant) rate series. */ +function flatRateSeries( + startTs: number, + endTs: number, + rate: number, + points = 100, +) { + return makeRateSeries(startTs, endTs, rate, rate, points); +} + +/** Build a FiatRateSeriesCache with a single coin/interval series. */ +function makeCache( + quoteCurrency: string, + coin: string, + interval: '1D' | '1W' | '1M' | '3M' | '1Y' | '5Y' | 'ALL', + points: {ts: number; rate: number}[], +): FiatRateSeriesCache { + const key = getFiatRateSeriesCacheKey(quoteCurrency, coin, interval); + return {[key]: {fetchedOn: Date.now(), points}}; +} + +/** Merge multiple caches. */ +function mergeCaches(...caches: FiatRateSeriesCache[]): FiatRateSeriesCache { + return Object.assign({}, ...caches); +} + +/** Build a minimal WalletForAnalysis with BTC (8 decimals). */ +function makeBtcWallet( + id: string, + snapshots: WalletForAnalysis['snapshots'], +): WalletForAnalysis { + return { + walletId: id, + walletName: `Wallet ${id}`, + currencyAbbreviation: 'btc', + credentials: {chain: 'btc'}, + snapshots, + }; +} + +/** Build a minimal WalletForAnalysis with ETH (18 decimals). */ +function makeEthWallet( + id: string, + snapshots: WalletForAnalysis['snapshots'], +): WalletForAnalysis { + return { + walletId: id, + walletName: `Wallet ${id}`, + currencyAbbreviation: 'eth', + credentials: {chain: 'eth'}, + snapshots, + }; +} + +/** Minimal snapshot — only the fields needed by analysis.ts. */ +function makeSnapshot( + walletId: string, + ts: number, + cryptoBalance: string, + opts: {markRate?: number; remainingCostBasisFiat?: number} = {}, +) { + return { + id: `snap-${walletId}-${ts}`, + walletId, + chain: 'btc', + coin: 'btc', + network: 'livenet', + assetId: 'btc', + timestamp: ts, + eventType: 'tx' as const, + cryptoBalance, + remainingCostBasisFiat: opts.remainingCostBasisFiat ?? 0, + quoteCurrency: 'USD', + markRate: opts.markRate ?? 0, + }; +} + +// ─── Shared timeline constants ──────────────────────────────────────────────── + +const NOW = 1_700_000_000_000; // arbitrary fixed "now" +const ONE_DAY_AGO = NOW - MS_PER_DAY; +const ONE_WEEK_AGO = NOW - 7 * MS_PER_DAY; + +// ─── Empty wallet list ──────────────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — empty wallets', () => { + it('returns an empty result when wallets array is empty', () => { + const result = buildPnlAnalysisSeries({ + wallets: [], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: {}, + nowMs: NOW, + }); + + expect(result.coins).toHaveLength(0); + expect(result.points).toHaveLength(0); + expect(result.assetSummaries).toHaveLength(0); + expect(result.totalSummary).toEqual({ + pnlStart: 0, + pnlEnd: 0, + pnlChange: 0, + pnlPercent: 0, + }); + expect(result.driverCoin).toBe(''); + }); + + it('returns the correct timeframe and quoteCurrency on empty result', () => { + const result = buildPnlAnalysisSeries({ + wallets: [], + timeframe: '1W', + quoteCurrency: 'eur', + fiatRateSeriesCache: {}, + nowMs: NOW, + }); + + expect(result.timeframe).toBe('1W'); + expect(result.quoteCurrency).toBe('EUR'); // uppercased + }); +}); + +// ─── Missing rate cache ─────────────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — missing rate cache', () => { + it('throws when fiatRateSeriesCache has no entry for the coin', () => { + const wallet = makeBtcWallet('w1', []); + expect(() => + buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: {}, + nowMs: NOW, + }), + ).toThrow(); + }); +}); + +// ─── Single wallet, flat rate (zero PnL) ────────────────────────────────────── + +describe('buildPnlAnalysisSeries — single BTC wallet, flat rate', () => { + const rateUSD = 30_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const points = flatRateSeries(seriesStart, seriesEnd, rateUSD); + const cache = makeCache('USD', 'btc', '1D', points); + + // Wallet holds 1 BTC (= 100_000_000 satoshis) from before the window start. + const ONE_BTC = '100000000'; + const snapBeforeWindow = makeSnapshot( + 'w1', + seriesStart - MS_PER_HOUR, + ONE_BTC, + { + markRate: rateUSD, + }, + ); + const wallet = makeBtcWallet('w1', [snapBeforeWindow]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 5, + }); + }); + + it('produces exactly maxPoints points', () => { + expect(result.points).toHaveLength(5); + }); + + it('identifies btc as the driver coin', () => { + expect(result.driverCoin).toBe('btc'); + }); + + it('all points have zero unrealized PnL when rate is flat and no new deposits', () => { + for (const pt of result.points) { + // Cost basis is set to value at window start (1 BTC * rateUSD). + // With flat rate, fiatBalance == costBasis → PnL ≈ 0. + expect(pt.totalUnrealizedPnlFiat).toBeCloseTo(0, 2); + } + }); + + it('totalFiatBalance matches 1 BTC at the flat rate', () => { + for (const pt of result.points) { + expect(pt.totalFiatBalance).toBeCloseTo(rateUSD, 2); + } + }); + + it('totalPnlPercent is 0 when rate is flat', () => { + for (const pt of result.points) { + expect(pt.totalPnlPercent).toBeCloseTo(0, 5); + } + }); + + it('ratePercentChange is 0 when rate is flat', () => { + for (const pt of result.points) { + expect(pt.ratePercentChange).toBeCloseTo(0, 5); + } + }); + + it('totalSummary.pnlChange is 0 for flat rate', () => { + expect(result.totalSummary.pnlChange).toBeCloseTo(0, 2); + }); + + it('assetSummary ratePercentChange is 0 for flat rate', () => { + const btcSummary = result.assetSummaries.find(s => s.coin === 'btc'); + expect(btcSummary).toBeDefined(); + expect(btcSummary!.ratePercentChange).toBeCloseTo(0, 5); + }); +}); + +// ─── Rate doubles → 100% PnL ───────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — BTC rate doubles over window', () => { + const startRate = 20_000; + const endRate = 40_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const points = makeRateSeries(seriesStart, seriesEnd, startRate, endRate); + const cache = makeCache('USD', 'btc', '1D', points); + + const ONE_BTC = '100000000'; // 1 BTC in satoshis + const snapBeforeWindow = makeSnapshot( + 'w1', + seriesStart - MS_PER_HOUR, + ONE_BTC, + { + markRate: startRate, + }, + ); + const wallet = makeBtcWallet('w1', [snapBeforeWindow]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }); + }); + + it('last point fiatBalance equals 1 BTC * endRate', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalFiatBalance).toBeCloseTo(endRate, 0); + }); + + it('last point totalUnrealizedPnlFiat equals endRate - startRate for 1 BTC', () => { + const last = result.points[result.points.length - 1]; + // Cost basis = 1 BTC * startRate = 20_000. Fiat value = 40_000. PnL = 20_000. + expect(last.totalUnrealizedPnlFiat).toBeCloseTo(endRate - startRate, 0); + }); + + it('last point totalPnlPercent is ~100% when rate doubles', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalPnlPercent).toBeCloseTo(100, 0); + }); + + it('ratePercentChange at last point is ~100%', () => { + const last = result.points[result.points.length - 1]; + expect(last.ratePercentChange).toBeCloseTo(100, 0); + }); + + it('totalSummary.pnlChange is positive', () => { + expect(result.totalSummary.pnlChange).toBeGreaterThan(0); + }); + + it('assetSummary ratePercentChange is ~100%', () => { + const btcSummary = result.assetSummaries.find(s => s.coin === 'btc'); + expect(btcSummary).toBeDefined(); + expect(btcSummary!.ratePercentChange).toBeCloseTo(100, 0); + }); + + it('assetSummary pnlChange is positive', () => { + const btcSummary = result.assetSummaries.find(s => s.coin === 'btc'); + expect(btcSummary!.pnlChange).toBeGreaterThan(0); + }); +}); + +// ─── Rate halves → −50% PnL ────────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — BTC rate halves over window (negative PnL)', () => { + const startRate = 40_000; + const endRate = 20_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = makeRateSeries(seriesStart, seriesEnd, startRate, endRate); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + + const ONE_BTC = '100000000'; + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, ONE_BTC, { + markRate: startRate, + }); + const wallet = makeBtcWallet('w1', [snap]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }); + }); + + it('last point totalUnrealizedPnlFiat is negative', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalUnrealizedPnlFiat).toBeLessThan(0); + }); + + it('last point totalPnlPercent is approximately −50%', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalPnlPercent).toBeCloseTo(-50, 0); + }); + + it('totalSummary.pnlChange is negative', () => { + expect(result.totalSummary.pnlChange).toBeLessThan(0); + }); + + it('assetSummary rateChange is negative', () => { + const btcSummary = result.assetSummaries.find(s => s.coin === 'btc'); + expect(btcSummary!.rateChange).toBeLessThan(0); + }); +}); + +// ─── Zero balance wallet ────────────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — wallet with zero balance', () => { + const startRate = 30_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, startRate); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + + // Wallet has zero balance the whole time + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '0', { + markRate: startRate, + }); + const wallet = makeBtcWallet('w1', [snap]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 5, + }); + }); + + it('all points have zero fiat balance', () => { + for (const pt of result.points) { + expect(pt.totalFiatBalance).toBe(0); + } + }); + + it('all points have zero PnL', () => { + for (const pt of result.points) { + expect(pt.totalUnrealizedPnlFiat).toBe(0); + expect(pt.totalPnlPercent).toBe(0); + } + }); +}); + +// ─── No snapshots before window start (wallet created mid-window) ───────────── + +describe('buildPnlAnalysisSeries — wallet with no snapshot before window', () => { + const startRate = 25_000; + const midRate = 30_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + // Series covers the whole day + const ratePoints = makeRateSeries(seriesStart, seriesEnd, startRate, midRate); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + + // First snapshot arrives MID-window (bought 0.5 BTC halfway through) + const midTs = seriesStart + (seriesEnd - seriesStart) / 2; + const HALF_BTC = '50000000'; // 0.5 BTC + + const snap = makeSnapshot('w1', midTs, HALF_BTC, {markRate: midRate}); + const wallet = makeBtcWallet('w1', [snap]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 5, + }); + }); + + it('first point has zero fiat balance (wallet not created yet)', () => { + const first = result.points[0]; + expect(first.totalFiatBalance).toBeCloseTo(0, 2); + }); + + it('last point has non-zero fiat balance after deposit', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalFiatBalance).toBeGreaterThan(0); + }); +}); + +// ─── quoteCurrency normalisation ───────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — quoteCurrency is uppercased', () => { + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, 30_000); + // Cache keyed as lowercase "usd" - note: buildPnlAnalysisSeries uppercases + // quoteCurrency before building the key, so we must store key as uppercase. + const cache = makeCache('USD', 'btc', '1D', ratePoints); + + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000'); + const wallet = makeBtcWallet('w1', [snap]); + + it('accepts lowercase quoteCurrency and uppercases it in result', () => { + const result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'usd', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }); + expect(result.quoteCurrency).toBe('USD'); + }); +}); + +// ─── currentRatesByCoin override ───────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — currentRatesByCoin override on last point', () => { + const startRate = 30_000; + const seriesRate = 35_000; + const overrideRate = 40_000; // spot rate override + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = makeRateSeries( + seriesStart, + seriesEnd, + startRate, + seriesRate, + ); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + + const ONE_BTC = '100000000'; + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, ONE_BTC, { + markRate: startRate, + }); + const wallet = makeBtcWallet('w1', [snap]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + currentRatesByCoin: {btc: overrideRate}, + }); + }); + + it('last point markRate uses the currentRatesByCoin override', () => { + const last = result.points[result.points.length - 1]; + expect(last.markRate).toBeCloseTo(overrideRate, 0); + }); + + it('last point fiatBalance uses override rate', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalFiatBalance).toBeCloseTo(overrideRate, 0); + }); + + it('non-last points use the series rate (not override)', () => { + const first = result.points[0]; + // First point rate should be around startRate, not overrideRate + expect(first.markRate).not.toBeCloseTo(overrideRate, -3); + }); +}); + +// ─── Multi-wallet, same coin ────────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — two BTC wallets', () => { + const rate = 50_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, rate); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + + const ONE_BTC = '100000000'; + const snap1 = makeSnapshot('w1', seriesStart - MS_PER_HOUR, ONE_BTC, { + markRate: rate, + }); + const snap2 = makeSnapshot('w2', seriesStart - MS_PER_HOUR, ONE_BTC, { + markRate: rate, + }); + + const wallet1 = makeBtcWallet('w1', [snap1]); + const wallet2 = makeBtcWallet('w2', [snap2]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet1, wallet2], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }); + }); + + it('coins list contains only btc (deduplicated)', () => { + expect(result.coins).toEqual(['btc']); + }); + + it('totalFiatBalance equals the sum of both wallets', () => { + // 2 BTC * 50_000 = 100_000 + for (const pt of result.points) { + expect(pt.totalFiatBalance).toBeCloseTo(2 * rate, 0); + } + }); + + it('byWalletId has entries for both wallets', () => { + const pt = result.points[0]; + expect(pt.byWalletId['w1']).toBeDefined(); + expect(pt.byWalletId['w2']).toBeDefined(); + }); + + it('totalCryptoBalanceAtomic sums both wallets (singleAsset=true)', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalCryptoBalanceAtomic).toBe('200000000'); + }); +}); + +// ─── Multi-coin portfolio ───────────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — BTC + ETH portfolio', () => { + const btcRate = 30_000; + const ethRate = 2_000; + const seriesStart = ONE_WEEK_AGO; + const seriesEnd = NOW; + + const btcRatePoints = flatRateSeries(seriesStart, seriesEnd, btcRate); + const ethRatePoints = flatRateSeries(seriesStart, seriesEnd, ethRate); + + const cache = mergeCaches( + makeCache('USD', 'btc', '1W', btcRatePoints), + makeCache('USD', 'eth', '1W', ethRatePoints), + ); + + const ONE_BTC = '100000000'; // 1 BTC + const ONE_ETH = '1000000000000000000'; // 1 ETH in wei + + const btcSnap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, ONE_BTC, { + markRate: btcRate, + }); + const ethSnap = { + id: 'snap-w2', + walletId: 'w2', + chain: 'eth', + coin: 'eth', + network: 'livenet', + assetId: 'eth', + timestamp: seriesStart - MS_PER_HOUR, + eventType: 'tx' as const, + cryptoBalance: ONE_ETH, + remainingCostBasisFiat: 0, + quoteCurrency: 'USD', + markRate: ethRate, + }; + + const btcWallet = makeBtcWallet('w1', [btcSnap]); + const ethWallet: WalletForAnalysis = { + walletId: 'w2', + walletName: 'ETH Wallet', + currencyAbbreviation: 'eth', + credentials: {chain: 'eth'}, + snapshots: [ethSnap], + }; + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [btcWallet, ethWallet], + timeframe: '1W', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 5, + }); + }); + + it('coins list contains both btc and eth (sorted alphabetically)', () => { + expect(result.coins).toEqual(['btc', 'eth']); + }); + + it('assetSummaries has one entry per coin', () => { + expect(result.assetSummaries).toHaveLength(2); + const coinNames = result.assetSummaries.map(s => s.coin).sort(); + expect(coinNames).toEqual(['btc', 'eth']); + }); + + it('totalFiatBalance equals BTC value + ETH value across all points', () => { + const expected = btcRate + ethRate; // 1 BTC + 1 ETH at flat rates + for (const pt of result.points) { + expect(pt.totalFiatBalance).toBeCloseTo(expected, 0); + } + }); + + it('totalCryptoBalanceAtomic is undefined for multi-coin portfolio', () => { + for (const pt of result.points) { + expect(pt.totalCryptoBalanceAtomic).toBeUndefined(); + } + }); + + it('driverCoin is the one with more rate points (both equal here, so alphabetical: btc)', () => { + // Both series have same length → tie-break alphabetically → btc < eth + expect(result.driverCoin).toBe('btc'); + }); +}); + +// ─── 3M / 1Y / 5Y use ALL interval ─────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — 3M/1Y/5Y use ALL interval from cache', () => { + const seriesStart = NOW - 400 * MS_PER_DAY; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, 25_000, 200); + // Store in "ALL" interval (that's what 3M/1Y/5Y look up) + const cache = makeCache('USD', 'btc', 'ALL', ratePoints); + + const ONE_BTC = '100000000'; + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, ONE_BTC, { + markRate: 25_000, + }); + const wallet = makeBtcWallet('w1', [snap]); + + it('does not throw for 3M timeframe when ALL series is cached', () => { + expect(() => + buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '3M', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }), + ).not.toThrow(); + }); + + it('does not throw for 1Y timeframe when ALL series is cached', () => { + expect(() => + buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1Y', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }), + ).not.toThrow(); + }); + + it('does not throw for 5Y timeframe when ALL series is cached', () => { + expect(() => + buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '5Y', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }), + ).not.toThrow(); + }); +}); + +// ─── Deposit mid-window raises cost basis ──────────────────────────────────── + +describe('buildPnlAnalysisSeries — deposit mid-window increases cost basis', () => { + const startRate = 20_000; + const endRate = 40_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = makeRateSeries(seriesStart, seriesEnd, startRate, endRate); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + + // Start with 0 BTC + const snapEmpty = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '0', { + markRate: startRate, + }); + // Buy 1 BTC mid-window at rate ~30_000 + const midTs = Math.round(seriesStart + (seriesEnd - seriesStart) / 2); + const midRate = (startRate + endRate) / 2; // 30_000 + const snapDeposit = makeSnapshot('w1', midTs, '100000000', { + markRate: midRate, + }); + const wallet = makeBtcWallet('w1', [snapEmpty, snapDeposit]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 5, + }); + }); + + it('last point has positive PnL (bought at 30k, now worth 40k)', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalUnrealizedPnlFiat).toBeGreaterThan(0); + }); + + it('cost basis after deposit equals approx 1 BTC * midRate', () => { + const last = result.points[result.points.length - 1]; + // Cost basis should be approx 30_000 (the rate when deposited) + expect(last.totalRemainingCostBasisFiat).toBeCloseTo(midRate, -2); + }); +}); + +// ─── Withdrawal reduces cost basis proportionally ──────────────────────────── + +describe('buildPnlAnalysisSeries — withdrawal pro-rata cost basis reduction', () => { + const rate = 40_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, rate); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + + // Start with 2 BTC + const TWO_BTC = '200000000'; + const snapStart = makeSnapshot('w1', seriesStart - MS_PER_HOUR, TWO_BTC, { + markRate: rate, + }); + // Withdraw 1 BTC at 3/4 of the way through + const withdrawTs = Math.round(seriesStart + (seriesEnd - seriesStart) * 0.75); + const ONE_BTC = '100000000'; + const snapWithdraw = makeSnapshot('w1', withdrawTs, ONE_BTC, { + markRate: rate, + }); + const wallet = makeBtcWallet('w1', [snapStart, snapWithdraw]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 5, + }); + }); + + it('last point balance is 1 BTC', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalFiatBalance).toBeCloseTo(rate, 0); + }); + + it('cost basis after withdrawal is half the original (average cost)', () => { + const last = result.points[result.points.length - 1]; + // Original basis = 2 BTC * rate = 80_000. After selling half → 40_000. + expect(last.totalRemainingCostBasisFiat).toBeCloseTo(rate, -2); + }); + + it('pnl remains near zero with flat rate after withdrawal', () => { + const last = result.points[result.points.length - 1]; + expect(last.totalUnrealizedPnlFiat).toBeCloseTo(0, 0); + }); +}); + +// ─── maxPoints defaulting ───────────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — maxPoints defaults to 91', () => { + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, 30_000, 200); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000', { + markRate: 30_000, + }); + const wallet = makeBtcWallet('w1', [snap]); + + it('produces exactly 91 points when maxPoints is not specified', () => { + const result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + // maxPoints not specified → default 91 + }); + expect(result.points).toHaveLength(91); + }); +}); + +// ─── nowMs defaults to Date.now() ──────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — nowMs defaults to Date.now()', () => { + it('does not throw when nowMs is omitted', () => { + // Use a series ending at Date.now() so the cache covers the window. + const seriesEnd = Date.now(); + const seriesStart = seriesEnd - MS_PER_DAY; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, 30_000, 200); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000'); + const wallet = makeBtcWallet('w1', [snap]); + + expect(() => + buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + maxPoints: 3, + }), + ).not.toThrow(); + }); +}); + +// ─── Timeline properties ────────────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — timeline monotonicity and bounds', () => { + const seriesStart = ONE_WEEK_AGO; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, 30_000, 200); + const cache = makeCache('USD', 'btc', '1W', ratePoints); + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000', { + markRate: 30_000, + }); + const wallet = makeBtcWallet('w1', [snap]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1W', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 10, + }); + }); + + it('points are monotonically non-decreasing in timestamp', () => { + for (let i = 1; i < result.points.length; i++) { + expect(result.points[i].timestamp).toBeGreaterThanOrEqual( + result.points[i - 1].timestamp, + ); + } + }); + + it('first point timestamp is >= series start', () => { + expect(result.points[0].timestamp).toBeGreaterThanOrEqual(seriesStart); + }); + + it('last point timestamp is <= series end', () => { + const last = result.points[result.points.length - 1]; + expect(last.timestamp).toBeLessThanOrEqual(seriesEnd); + }); + + it('produces exactly maxPoints points', () => { + expect(result.points).toHaveLength(10); + }); +}); + +// ─── displaySymbol is uppercase coin ───────────────────────────────────────── + +describe('buildPnlAnalysisSeries — assetSummary displaySymbol', () => { + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, 30_000); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000'); + const wallet = makeBtcWallet('w1', [snap]); + + it('displaySymbol is the uppercased coin name', () => { + const result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }); + const btcSummary = result.assetSummaries.find(s => s.coin === 'btc'); + expect(btcSummary!.displaySymbol).toBe('BTC'); + }); +}); + +// ─── wallets returned in result ─────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — wallets field in result', () => { + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, 30_000); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000'); + const wallet = makeBtcWallet('w1', [snap]); + + it('result.wallets contains the input wallets', () => { + const result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }); + expect(result.wallets).toHaveLength(1); + expect(result.wallets[0].walletId).toBe('w1'); + }); +}); + +// ─── pnlPercent in byWalletId ───────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — per-wallet pnlPercent', () => { + const startRate = 10_000; + const endRate = 15_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = makeRateSeries(seriesStart, seriesEnd, startRate, endRate); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000', { + markRate: startRate, + }); + const wallet = makeBtcWallet('w1', [snap]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }); + }); + + it('per-wallet pnlPercent at last point is ~50% (rate went from 10k to 15k)', () => { + const last = result.points[result.points.length - 1]; + const walletPoint = last.byWalletId['w1']; + expect(walletPoint.pnlPercent).toBeCloseTo(50, 0); + }); + + it('per-wallet ratePercentChange at last point is ~50%', () => { + const last = result.points[result.points.length - 1]; + const walletPoint = last.byWalletId['w1']; + expect(walletPoint.ratePercentChange).toBeCloseTo(50, 0); + }); + + it('per-wallet balanceAtomic equals the snapshot balance', () => { + const last = result.points[result.points.length - 1]; + const walletPoint = last.byWalletId['w1']; + expect(walletPoint.balanceAtomic).toBe('100000000'); + }); + + it('per-wallet markRate is the current rate at that point', () => { + const last = result.points[result.points.length - 1]; + const walletPoint = last.byWalletId['w1']; + expect(walletPoint.markRate).toBeCloseTo(endRate, 0); + }); +}); + +// ─── totalSummary structure ─────────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — totalSummary structure', () => { + const startRate = 20_000; + const endRate = 22_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = makeRateSeries(seriesStart, seriesEnd, startRate, endRate); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000', { + markRate: startRate, + }); + const wallet = makeBtcWallet('w1', [snap]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 5, + }); + }); + + it('totalSummary.pnlEnd equals last point totalUnrealizedPnlFiat', () => { + const last = result.points[result.points.length - 1]; + expect(result.totalSummary.pnlEnd).toBeCloseTo( + last.totalUnrealizedPnlFiat, + 2, + ); + }); + + it('totalSummary.pnlStart equals first point totalUnrealizedPnlFiat', () => { + const first = result.points[0]; + expect(result.totalSummary.pnlStart).toBeCloseTo( + first.totalUnrealizedPnlFiat, + 2, + ); + }); + + it('totalSummary.pnlChange = pnlEnd - pnlStart', () => { + const {pnlStart, pnlEnd, pnlChange} = result.totalSummary; + expect(pnlChange).toBeCloseTo(pnlEnd - pnlStart, 2); + }); + + it('totalSummary.pnlPercent matches last point totalPnlPercent', () => { + const last = result.points[result.points.length - 1]; + expect(result.totalSummary.pnlPercent).toBeCloseTo(last.totalPnlPercent, 2); + }); +}); + +// ─── MATIC / POL normalization ──────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — MATIC normalizes to pol in cache key', () => { + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const rate = 0.8; + // Cache key must use "pol" because normalizeFiatRateSeriesCoin maps matic→pol + const ratePoints = flatRateSeries(seriesStart, seriesEnd, rate); + const cache = makeCache('USD', 'pol', '1D', ratePoints); + + const maticWallet: WalletForAnalysis = { + walletId: 'w-matic', + walletName: 'MATIC Wallet', + currencyAbbreviation: 'matic', + credentials: {chain: 'matic'}, + snapshots: [ + { + id: 'snap-matic', + walletId: 'w-matic', + chain: 'matic', + coin: 'matic', + network: 'livenet', + assetId: 'matic', + timestamp: seriesStart - MS_PER_HOUR, + eventType: 'tx' as const, + cryptoBalance: '1000000000000000000', // 1 MATIC (18 decimals) + remainingCostBasisFiat: 0, + quoteCurrency: 'USD', + markRate: rate, + }, + ], + }; + + it('does not throw when matic abbreviation is used with pol cache key', () => { + expect(() => + buildPnlAnalysisSeries({ + wallets: [maticWallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }), + ).not.toThrow(); + }); + + it('driverCoin is pol (normalized from matic)', () => { + const result = buildPnlAnalysisSeries({ + wallets: [maticWallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }); + expect(result.driverCoin).toBe('pol'); + }); +}); + +// ─── Fallback to wider interval ─────────────────────────────────────────────── + +describe('buildPnlAnalysisSeries — rate cache fallback to wider interval', () => { + const seriesStart = ONE_WEEK_AGO; + const seriesEnd = NOW; + const ratePoints = flatRateSeries(seriesStart, seriesEnd, 30_000, 200); + // Store under 1W but request 1D — fallback order for 1D includes 1W + const cache = makeCache('USD', 'btc', '1W', ratePoints); + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000', { + markRate: 30_000, + }); + const wallet = makeBtcWallet('w1', [snap]); + + it('uses wider series as fallback when exact interval is missing', () => { + // 1D is requested but only 1W is in cache — fallback allows using 1W + expect(() => + buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 3, + }), + ).not.toThrow(); + }); +}); + +// ─── assetSummary rateStart / rateEnd ───────────────────────────────────────── + +describe('buildPnlAnalysisSeries — assetSummary rate values', () => { + const startRate = 10_000; + const endRate = 12_000; + const seriesStart = ONE_DAY_AGO; + const seriesEnd = NOW; + const ratePoints = makeRateSeries(seriesStart, seriesEnd, startRate, endRate); + const cache = makeCache('USD', 'btc', '1D', ratePoints); + const snap = makeSnapshot('w1', seriesStart - MS_PER_HOUR, '100000000', { + markRate: startRate, + }); + const wallet = makeBtcWallet('w1', [snap]); + + let result: ReturnType; + + beforeAll(() => { + result = buildPnlAnalysisSeries({ + wallets: [wallet], + timeframe: '1D', + quoteCurrency: 'USD', + fiatRateSeriesCache: cache, + nowMs: NOW, + maxPoints: 5, + }); + }); + + it('assetSummary rateStart approximates the rate at the window start', () => { + const btcSummary = result.assetSummaries.find(s => s.coin === 'btc')!; + expect(btcSummary.rateStart).toBeCloseTo(startRate, -2); + }); + + it('assetSummary rateEnd approximates the rate at window end', () => { + const btcSummary = result.assetSummaries.find(s => s.coin === 'btc')!; + expect(btcSummary.rateEnd).toBeCloseTo(endRate, -2); + }); + + it('assetSummary rateChange = rateEnd - rateStart', () => { + const btcSummary = result.assetSummaries.find(s => s.coin === 'btc')!; + expect(btcSummary.rateChange).toBeCloseTo( + btcSummary.rateEnd - btcSummary.rateStart, + 2, + ); + }); + + it('assetSummary ratePercentChange is ~20% (10k→12k)', () => { + const btcSummary = result.assetSummaries.find(s => s.coin === 'btc')!; + expect(btcSummary.ratePercentChange).toBeCloseTo(20, 0); + }); +}); diff --git a/src/utils/portfolio/core/pnl/rates.spec.ts b/src/utils/portfolio/core/pnl/rates.spec.ts new file mode 100644 index 0000000000..f7f0b64d44 --- /dev/null +++ b/src/utils/portfolio/core/pnl/rates.spec.ts @@ -0,0 +1,635 @@ +/** + * Tests for src/utils/portfolio/core/pnl/rates.ts + * + * Covers normalizeFiatRateSeriesCoin and createFiatRateLookup with + * concrete numeric inputs to catch regressions in rate-lookup math. + */ +import {normalizeFiatRateSeriesCoin, createFiatRateLookup} from './rates'; +import type {FiatRateSeriesCache} from '../fiatRateSeries'; +import {getFiatRateSeriesCacheKey} from '../fiatRateSeries'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const MS_PER_HOUR = 60 * 60 * 1000; + +/** Build a FiatRateSeriesCache with a single coin/interval series. */ +function makeCache( + quoteCurrency: string, + coin: string, + interval: '1D' | '1W' | '1M' | '3M' | '1Y' | '5Y' | 'ALL', + points: {ts: number; rate: number}[], +): FiatRateSeriesCache { + const key = getFiatRateSeriesCacheKey(quoteCurrency, coin, interval); + return {[key]: {fetchedOn: Date.now(), points}}; +} + +/** Merge multiple caches into one. */ +function mergeCaches(...caches: FiatRateSeriesCache[]): FiatRateSeriesCache { + return Object.assign({}, ...caches); +} + +/** Build evenly-spaced points from startTs to endTs. */ +function makePoints( + startTs: number, + endTs: number, + startRate: number, + endRate: number, + count = 50, +): {ts: number; rate: number}[] { + const pts: {ts: number; rate: number}[] = []; + for (let i = 0; i < count; i++) { + const frac = count === 1 ? 0 : i / (count - 1); + pts.push({ + ts: Math.round(startTs + (endTs - startTs) * frac), + rate: startRate + (endRate - startRate) * frac, + }); + } + return pts; +} + +/** Build a flat (constant-rate) series. */ +function flatPoints( + startTs: number, + endTs: number, + rate: number, + count = 50, +): {ts: number; rate: number}[] { + return makePoints(startTs, endTs, rate, rate, count); +} + +// ─── normalizeFiatRateSeriesCoin ────────────────────────────────────────────── + +describe('normalizeFiatRateSeriesCoin', () => { + it('returns "pol" for "matic"', () => { + expect(normalizeFiatRateSeriesCoin('matic')).toBe('pol'); + }); + + it('returns "pol" for "MATIC" (case-insensitive)', () => { + expect(normalizeFiatRateSeriesCoin('MATIC')).toBe('pol'); + }); + + it('returns "pol" for "pol"', () => { + expect(normalizeFiatRateSeriesCoin('pol')).toBe('pol'); + }); + + it('returns "pol" for "POL" (case-insensitive)', () => { + expect(normalizeFiatRateSeriesCoin('POL')).toBe('pol'); + }); + + it('lowercases btc', () => { + expect(normalizeFiatRateSeriesCoin('BTC')).toBe('btc'); + }); + + it('lowercases eth', () => { + expect(normalizeFiatRateSeriesCoin('ETH')).toBe('eth'); + }); + + it('lowercases arbitrary uppercase coin', () => { + expect(normalizeFiatRateSeriesCoin('SOL')).toBe('sol'); + }); + + it('handles already-lowercase coin', () => { + expect(normalizeFiatRateSeriesCoin('sol')).toBe('sol'); + }); + + it('returns empty string for undefined', () => { + expect(normalizeFiatRateSeriesCoin(undefined)).toBe(''); + }); + + it('returns empty string for empty string', () => { + expect(normalizeFiatRateSeriesCoin('')).toBe(''); + }); + + it('does not alter non-matic coins', () => { + expect(normalizeFiatRateSeriesCoin('usdc')).toBe('usdc'); + expect(normalizeFiatRateSeriesCoin('xrp')).toBe('xrp'); + }); +}); + +// ─── createFiatRateLookup — basic rate resolution ──────────────────────────── + +describe('createFiatRateLookup — basic nearest-rate lookup', () => { + const nowMs = 1_000 * MS_PER_DAY; // arbitrary epoch anchor + + it('returns the exact rate when the target ts matches a point exactly', () => { + const ts = nowMs - MS_PER_HOUR; // 1-hour-old → age ≤ 1D → prefers 1D + const cache = makeCache('USD', 'btc', '1D', [{ts, rate: 50000}]); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(50000); + }); + + it('picks the nearest point when no exact match exists', () => { + const base = nowMs - 2 * MS_PER_HOUR; + const cache = makeCache('USD', 'btc', '1D', [ + {ts: base, rate: 40000}, + {ts: base + MS_PER_HOUR, rate: 45000}, + {ts: base + 2 * MS_PER_HOUR, rate: 50000}, + ]); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + // target is 15 min after second point — closer to second than third + const target = base + MS_PER_HOUR + 15 * 60 * 1000; + expect(lookup.getNearestRate(target)).toBe(45000); + }); + + it('returns undefined when cache is empty', () => { + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache: {}, + nowMs, + }); + expect(lookup.getNearestRate(nowMs - MS_PER_HOUR)).toBeUndefined(); + }); + + it('returns undefined for a non-finite target timestamp', () => { + const cache = makeCache('USD', 'btc', '1D', [ + {ts: nowMs - MS_PER_HOUR, rate: 50000}, + ]); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(NaN)).toBeUndefined(); + expect(lookup.getNearestRate(Infinity)).toBeUndefined(); + expect(lookup.getNearestRate(-Infinity)).toBeUndefined(); + }); + + it('resolves rate from 1W series when age is between 1D and 7D', () => { + const ts = nowMs - 3 * MS_PER_DAY; // age = 3 days → prefers 1W + const cache = makeCache( + 'USD', + 'btc', + '1W', + flatPoints(nowMs - 7 * MS_PER_DAY, nowMs, 30000), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + const rate = lookup.getNearestRate(ts); + expect(typeof rate).toBe('number'); + expect(rate).toBeCloseTo(30000, 0); + }); + + it('resolves rate from ALL series for very old timestamps (> 5Y)', () => { + const veryOldTs = nowMs - 2000 * MS_PER_DAY; // ~5.5 years ago + const cache = makeCache('USD', 'btc', 'ALL', [ + {ts: veryOldTs - MS_PER_DAY, rate: 5000}, + {ts: veryOldTs, rate: 6000}, + {ts: veryOldTs + MS_PER_DAY, rate: 7000}, + ]); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(veryOldTs)).toBe(6000); + }); + + it('falls back to a lower-priority interval when preferred is absent', () => { + // Age = 2 hours → prefers 1D, but 1D is empty; 1W is populated + const ts = nowMs - 2 * MS_PER_HOUR; + const cache = makeCache('USD', 'btc', '1W', [ + {ts: ts - MS_PER_HOUR, rate: 20000}, + {ts, rate: 21000}, + ]); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + const rate = lookup.getNearestRate(ts); + expect(rate).toBe(21000); + }); + + it('skips points with non-finite rates and falls back to next interval', () => { + const ts = nowMs - MS_PER_HOUR; + // 1D has NaN rate, 1W has a valid rate + const cache = mergeCaches( + makeCache('USD', 'btc', '1D', [{ts, rate: NaN}]), + makeCache('USD', 'btc', '1W', [{ts: ts - 60000, rate: 99999}]), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(99999); + }); + + it('handles out-of-order points by sorting them', () => { + // Points deliberately unsorted + const ts = nowMs - MS_PER_HOUR; + const cache = makeCache('USD', 'btc', '1D', [ + {ts: ts + 30 * 60 * 1000, rate: 52000}, + {ts: ts - 30 * 60 * 1000, rate: 48000}, + {ts, rate: 50000}, + ]); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + // Target equals the middle point exactly + expect(lookup.getNearestRate(ts)).toBe(50000); + }); + + it('handles monotonically increasing queries efficiently (fast path)', () => { + const start = nowMs - MS_PER_HOUR; + const pts = flatPoints(start, nowMs, 1000, 10); + const cache = makeCache('USD', 'btc', '1D', pts); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + // Query each point in ascending order + for (const p of pts) { + expect(lookup.getNearestRate(p.ts)).toBe(1000); + } + }); + + it('handles non-monotonic (backward) queries via binary search', () => { + const start = nowMs - 10 * MS_PER_HOUR; + const pts = makePoints(start, nowMs, 100, 200, 20); + const cache = makeCache('USD', 'btc', '1D', pts); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + // Query newest first, then oldest + const newestRate = lookup.getNearestRate(nowMs); + const oldestRate = lookup.getNearestRate(start); + expect(typeof newestRate).toBe('number'); + expect(typeof oldestRate).toBe('number'); + expect(newestRate).toBeGreaterThan(oldestRate!); + }); + + it('returns the single point when array has exactly one element', () => { + const ts = nowMs - MS_PER_HOUR; + const cache = makeCache('USD', 'btc', '1D', [{ts, rate: 77777}]); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(77777); + // Target before the only point → still returns it (clamped) + expect(lookup.getNearestRate(ts - MS_PER_HOUR)).toBe(77777); + // Target after the only point → still returns it (no right neighbor) + expect(lookup.getNearestRate(ts + MS_PER_HOUR)).toBe(77777); + }); +}); + +// ─── createFiatRateLookup — BTC-bridge fallback ────────────────────────────── + +describe('createFiatRateLookup — BTC-bridge currency conversion', () => { + const nowMs = 2_000 * MS_PER_DAY; + const ts = nowMs - MS_PER_HOUR; + + /** + * Helper: build a cache with: + * coin@bridge = bridgeCoinRate + * BTC@bridge = bridgeBtcRate + * BTC@source = sourceBtcRate + * + * Expected synthesized coin@source = bridgeCoinRate * (sourceBtcRate / bridgeBtcRate) + */ + function makeBridgeCache(args: { + coin: string; + bridgeQuoteCurrency: string; + sourceQuoteCurrency: string; + bridgeCoinRate: number; + bridgeBtcRate: number; + sourceBtcRate: number; + }): FiatRateSeriesCache { + const pt = (rate: number) => [{ts, rate}]; + return mergeCaches( + makeCache( + args.bridgeQuoteCurrency, + args.coin, + '1D', + pt(args.bridgeCoinRate), + ), + makeCache(args.bridgeQuoteCurrency, 'btc', '1D', pt(args.bridgeBtcRate)), + makeCache(args.sourceQuoteCurrency, 'btc', '1D', pt(args.sourceBtcRate)), + ); + } + + it('synthesizes coin@source = bridgeCoinRate * (sourceBtcRate / bridgeBtcRate)', () => { + // ETH in EUR, bridge via USD: + // ETH@USD = 3000, BTC@USD = 60000, BTC@EUR = 50000 + // ETH@EUR ≈ 3000 * (50000 / 60000) = 2500 + const cache = makeBridgeCache({ + coin: 'eth', + bridgeQuoteCurrency: 'USD', + sourceQuoteCurrency: 'EUR', + bridgeCoinRate: 3000, + bridgeBtcRate: 60000, + sourceBtcRate: 50000, + }); + const lookup = createFiatRateLookup({ + quoteCurrency: 'EUR', + coin: 'eth', + cache, + nowMs, + bridgeQuoteCurrency: 'USD', + }); + const rate = lookup.getNearestRate(ts); + expect(rate).toBeCloseTo(2500, 5); + }); + + it('returns undefined from bridge when bridgeCoinRate is zero', () => { + const cache = makeBridgeCache({ + coin: 'eth', + bridgeQuoteCurrency: 'USD', + sourceQuoteCurrency: 'EUR', + bridgeCoinRate: 0, + bridgeBtcRate: 60000, + sourceBtcRate: 50000, + }); + const lookup = createFiatRateLookup({ + quoteCurrency: 'EUR', + coin: 'eth', + cache, + nowMs, + bridgeQuoteCurrency: 'USD', + }); + expect(lookup.getNearestRate(ts)).toBeUndefined(); + }); + + it('returns undefined from bridge when bridgeBtcRate is zero', () => { + const cache = makeBridgeCache({ + coin: 'eth', + bridgeQuoteCurrency: 'USD', + sourceQuoteCurrency: 'EUR', + bridgeCoinRate: 3000, + bridgeBtcRate: 0, + sourceBtcRate: 50000, + }); + const lookup = createFiatRateLookup({ + quoteCurrency: 'EUR', + coin: 'eth', + cache, + nowMs, + bridgeQuoteCurrency: 'USD', + }); + expect(lookup.getNearestRate(ts)).toBeUndefined(); + }); + + it('returns undefined from bridge when sourceBtcRate is zero', () => { + const cache = makeBridgeCache({ + coin: 'eth', + bridgeQuoteCurrency: 'USD', + sourceQuoteCurrency: 'EUR', + bridgeCoinRate: 3000, + bridgeBtcRate: 60000, + sourceBtcRate: 0, + }); + const lookup = createFiatRateLookup({ + quoteCurrency: 'EUR', + coin: 'eth', + cache, + nowMs, + bridgeQuoteCurrency: 'USD', + }); + expect(lookup.getNearestRate(ts)).toBeUndefined(); + }); + + it('does NOT use bridge for BTC (coin === btc) — resolves EUR:btc directly if present', () => { + // canBridge is always false when coin === 'btc', regardless of bridgeQuoteCurrency. + // If EUR:btc IS in the cache, it should be returned from the direct series. + const directTs = ts; + const cache = makeCache('EUR', 'btc', '1D', [{ts: directTs, rate: 50000}]); + const lookup = createFiatRateLookup({ + quoteCurrency: 'EUR', + coin: 'btc', + cache, + nowMs, + bridgeQuoteCurrency: 'USD', + }); + // Direct EUR:btc series is present — returns rate without bridging + expect(lookup.getNearestRate(directTs)).toBe(50000); + }); + + it('does NOT use bridge for BTC when direct EUR:btc series is absent', () => { + // No EUR:btc series at all — bridge is disabled for btc, so returns undefined + const cache = mergeCaches( + makeCache('USD', 'btc', '1D', [{ts, rate: 60000}]), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'EUR', + coin: 'btc', + cache, + nowMs, + bridgeQuoteCurrency: 'USD', + }); + expect(lookup.getNearestRate(ts)).toBeUndefined(); + }); + + it('does NOT use bridge when bridgeQuoteCurrency equals quoteCurrency', () => { + const cache = makeBridgeCache({ + coin: 'eth', + bridgeQuoteCurrency: 'USD', + sourceQuoteCurrency: 'USD', + bridgeCoinRate: 3000, + bridgeBtcRate: 60000, + sourceBtcRate: 60000, + }); + // The direct USD:eth:1D series IS in the cache, so it should resolve from there + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'eth', + cache, + nowMs, + bridgeQuoteCurrency: 'USD', // same as source — bridge disabled + }); + expect(lookup.getNearestRate(ts)).toBe(3000); + }); + + it('returns undefined when no bridge quote currency is provided and direct series is missing', () => { + const lookup = createFiatRateLookup({ + quoteCurrency: 'EUR', + coin: 'eth', + cache: {}, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBeUndefined(); + }); + + it('uses bridge lazily — bridge lookups are only created when needed', () => { + // Direct USD:eth:1D series present — bridge should NOT be consulted + const cache = mergeCaches( + makeCache('USD', 'eth', '1D', [{ts, rate: 3000}]), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'eth', + cache, + nowMs, + bridgeQuoteCurrency: 'EUR', + }); + // Should resolve directly without needing any bridge series + expect(lookup.getNearestRate(ts)).toBe(3000); + }); + + it('bridge result is positive and finite for valid inputs', () => { + const cache = makeBridgeCache({ + coin: 'sol', + bridgeQuoteCurrency: 'USD', + sourceQuoteCurrency: 'GBP', + bridgeCoinRate: 150, + bridgeBtcRate: 60000, + sourceBtcRate: 48000, + }); + const lookup = createFiatRateLookup({ + quoteCurrency: 'GBP', + coin: 'sol', + cache, + nowMs, + bridgeQuoteCurrency: 'USD', + }); + const rate = lookup.getNearestRate(ts); + // sol@GBP = 150 * (48000 / 60000) = 120 + expect(rate).toBeCloseTo(120, 5); + expect(rate).toBeGreaterThan(0); + expect(Number.isFinite(rate)).toBe(true); + }); +}); + +// ─── createFiatRateLookup — interval preference by age ─────────────────────── + +describe('createFiatRateLookup — interval preference by transaction age', () => { + const nowMs = 3_000 * MS_PER_DAY; + + const makeSimpleCache = ( + interval: '1D' | '1W' | '1M' | '3M' | '1Y' | '5Y' | 'ALL', + ts: number, + rate: number, + ) => makeCache('USD', 'btc', interval, [{ts, rate}]); + + it('prefers 1D series for timestamps within the last day', () => { + const ts = nowMs - 12 * MS_PER_HOUR; + const cache = mergeCaches( + makeSimpleCache('1D', ts, 11111), + makeSimpleCache('1W', ts, 22222), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(11111); + }); + + it('prefers 1W series for timestamps 1–7 days old', () => { + const ts = nowMs - 4 * MS_PER_DAY; + const cache = mergeCaches( + makeSimpleCache('1W', ts, 33333), + makeSimpleCache('1M', ts, 44444), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(33333); + }); + + it('prefers 1M series for timestamps 7–30 days old', () => { + const ts = nowMs - 15 * MS_PER_DAY; + const cache = mergeCaches( + makeSimpleCache('1M', ts, 55555), + makeSimpleCache('3M', ts, 66666), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(55555); + }); + + it('prefers 3M series for timestamps 30–90 days old', () => { + const ts = nowMs - 60 * MS_PER_DAY; + const cache = mergeCaches( + makeSimpleCache('3M', ts, 77777), + makeSimpleCache('1Y', ts, 88888), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(77777); + }); + + it('prefers 1Y series for timestamps 90–365 days old', () => { + const ts = nowMs - 200 * MS_PER_DAY; + const cache = mergeCaches( + makeSimpleCache('1Y', ts, 12345), + makeSimpleCache('5Y', ts, 99999), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(12345); + }); + + it('prefers 5Y series for timestamps 365–1825 days old', () => { + const ts = nowMs - 700 * MS_PER_DAY; + const cache = mergeCaches( + makeSimpleCache('5Y', ts, 8888), + makeSimpleCache('ALL', ts, 1111), + ); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(8888); + }); + + it('prefers ALL series for timestamps > 1825 days old', () => { + const ts = nowMs - 2000 * MS_PER_DAY; + const cache = makeSimpleCache('ALL', ts, 4444); + const lookup = createFiatRateLookup({ + quoteCurrency: 'USD', + coin: 'btc', + cache, + nowMs, + }); + expect(lookup.getNearestRate(ts)).toBe(4444); + }); +}); diff --git a/src/utils/portfolio/core/pnl/snapshots.spec.ts b/src/utils/portfolio/core/pnl/snapshots.spec.ts new file mode 100644 index 0000000000..5f18388a48 --- /dev/null +++ b/src/utils/portfolio/core/pnl/snapshots.spec.ts @@ -0,0 +1,1444 @@ +/** + * Tests for src/utils/portfolio/core/pnl/snapshots.ts + * + * Covers the exported pure functions: + * - getAssetIdFromWallet + * - extractTxIdFromSnapshotId + * - computeBalanceSnapshotComputed + * - buildBalanceSnapshots (synchronous entry point) + * - buildBalanceSnapshotsAsync (async entry point) + * + * The majority of internal helpers (isTxFailed, classifyTxFlow, etc.) are + * exercised indirectly through the two build* entry points, covering both + * sides of every conditional in the pipeline. + */ + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +// formatBigIntDecimal is used by atomicToUnitNumber → we provide a real-enough +// implementation so that atomic↔unit math works in tests. +jest.mock('../format', () => { + const actualModule = jest.requireActual('../format'); + return actualModule; +}); + +// rates.ts pulls in intervalPrefs constants; mock createFiatRateLookup so we +// can inject a controllable rate without building a real cache. +const mockGetNearestRate = jest.fn(); +jest.mock('./rates', () => ({ + normalizeFiatRateSeriesCoin: jest.fn((c: string) => (c || '').toLowerCase()), + createFiatRateLookup: jest.fn(() => ({ + getNearestRate: mockGetNearestRate, + })), +})); + +// ─── Imports ────────────────────────────────────────────────────────────────── + +import { + getAssetIdFromWallet, + extractTxIdFromSnapshotId, + computeBalanceSnapshotComputed, + buildBalanceSnapshots, + buildBalanceSnapshotsAsync, +} from './snapshots'; +import type {BuildBalanceSnapshotsArgs} from './snapshots'; +import type {BalanceSnapshotStored} from './types'; +import type {WalletSummary, WalletCredentials} from '../types'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const BTC_CREDS: WalletCredentials = {chain: 'btc', coin: 'btc'}; +const ETH_CREDS: WalletCredentials = {chain: 'eth', coin: 'eth'}; + +function makeSummary(overrides: Partial = {}): WalletSummary { + return { + walletId: 'test-wallet', + walletName: 'Test', + chain: 'btc', + network: 'livenet', + currencyAbbreviation: 'btc', + balanceAtomic: '0', + balanceFormatted: '0', + ...overrides, + }; +} + +/** + * Build a minimal BuildBalanceSnapshotsArgs for a BTC wallet with the given txs. + * The fiatRateSeriesCache can be empty because we mock createFiatRateLookup. + */ +function makeArgs( + overrides: Partial = {}, +): BuildBalanceSnapshotsArgs { + return { + wallet: makeSummary(), + credentials: BTC_CREDS, + txs: [], + quoteCurrency: 'USD', + fiatRateSeriesCache: {}, + ...overrides, + }; +} + +/** Unix seconds for a date string. */ +function ts(dateStr: string): number { + return Math.floor(new Date(dateStr).getTime() / 1000); +} + +/** Build a received tx object. */ +function receivedTx( + txid: string, + amount: number, + time: number, +): Record { + return {txid, action: 'received', amount, time}; +} + +/** Build a sent tx object. */ +function sentTx( + txid: string, + amount: number, + fees: number, + time: number, +): Record { + return {txid, action: 'sent', amount, fees, time}; +} + +/** Build a moved tx object. */ +function movedTx( + txid: string, + fees: number, + time: number, +): Record { + return {txid, action: 'moved', amount: 0, fees, time}; +} + +const RATE = 50_000; // $50,000 per BTC + +// ─── getAssetIdFromWallet ───────────────────────────────────────────────────── + +describe('getAssetIdFromWallet', () => { + it('returns chain:coin for a native coin', () => { + expect( + getAssetIdFromWallet({ + chain: 'btc', + currencyAbbreviation: 'btc', + tokenAddress: undefined, + }), + ).toBe('btc:btc'); + }); + + it('returns chain:coin for an ETH wallet', () => { + expect( + getAssetIdFromWallet({ + chain: 'eth', + currencyAbbreviation: 'eth', + tokenAddress: undefined, + }), + ).toBe('eth:eth'); + }); + + it('includes tokenAddress (lowercased) when present', () => { + expect( + getAssetIdFromWallet({ + chain: 'eth', + currencyAbbreviation: 'usdc', + tokenAddress: '0xABCDEF', + }), + ).toBe('eth:usdc:0xabcdef'); + }); + + it('lowercases chain and coin', () => { + expect( + getAssetIdFromWallet({ + chain: 'ETH', + currencyAbbreviation: 'USDC', + tokenAddress: undefined, + }), + ).toBe('eth:usdc'); + }); + + it('handles empty chain and coin gracefully', () => { + expect( + getAssetIdFromWallet({ + chain: '', + currencyAbbreviation: '', + tokenAddress: undefined, + }), + ).toBe(':'); + }); + + it('handles undefined chain and coin gracefully', () => { + expect( + getAssetIdFromWallet({ + chain: undefined as any, + currencyAbbreviation: undefined as any, + tokenAddress: undefined, + }), + ).toBe(':'); + }); +}); + +// ─── extractTxIdFromSnapshotId ──────────────────────────────────────────────── + +describe('extractTxIdFromSnapshotId', () => { + it('returns null when input does not start with "tx:"', () => { + expect(extractTxIdFromSnapshotId('daily:2024-01-01')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(extractTxIdFromSnapshotId('')).toBeNull(); + }); + + it('returns null for null/undefined coerced', () => { + expect(extractTxIdFromSnapshotId(null as any)).toBeNull(); + expect(extractTxIdFromSnapshotId(undefined as any)).toBeNull(); + }); + + it('extracts a simple txid', () => { + expect(extractTxIdFromSnapshotId('tx:abc123')).toBe('abc123'); + }); + + it('extracts a txid that itself contains colons', () => { + // Our fallback ID format: "time:action:amount:fees" + expect(extractTxIdFromSnapshotId('tx:1234:sent:500:10')).toBe( + '1234:sent:500:10', + ); + }); + + it('returns null when there is nothing after "tx:"', () => { + expect(extractTxIdFromSnapshotId('tx:')).toBeNull(); + }); + + it('handles a hex txid', () => { + const hex = '0xdeadbeef'; + expect(extractTxIdFromSnapshotId(`tx:${hex}`)).toBe(hex); + }); +}); + +// ─── computeBalanceSnapshotComputed ────────────────────────────────────────── + +describe('computeBalanceSnapshotComputed', () => { + const baseStored: BalanceSnapshotStored = { + id: 'tx:abc', + walletId: 'w1', + chain: 'btc', + coin: 'btc', + network: 'livenet', + assetId: 'btc:btc', + timestamp: 1_700_000_000_000, + eventType: 'tx', + cryptoBalance: '100000000', // 1 BTC in satoshis + remainingCostBasisFiat: 30_000, + quoteCurrency: 'USD', + markRate: 50_000, + createdAt: Date.now(), + }; + + it('computes fiatBalance = unitsHeld * markRate', () => { + const result = computeBalanceSnapshotComputed(baseStored, BTC_CREDS); + // 1 BTC at $50k = $50k + expect(result.fiatBalance).toBeCloseTo(50_000, 2); + }); + + it('computes unrealizedPnlFiat = fiatBalance - remainingCostBasisFiat', () => { + const result = computeBalanceSnapshotComputed(baseStored, BTC_CREDS); + // $50k - $30k = $20k gain + expect(result.unrealizedPnlFiat).toBeCloseTo(20_000, 2); + }); + + it('computes avgCostFiatPerUnit = remainingCostBasisFiat / unitsHeld', () => { + const result = computeBalanceSnapshotComputed(baseStored, BTC_CREDS); + // $30k / 1 BTC = $30k average cost + expect(result.avgCostFiatPerUnit).toBeCloseTo(30_000, 2); + }); + + it('sets avgCostFiatPerUnit to 0 when balance is 0', () => { + const stored: BalanceSnapshotStored = { + ...baseStored, + cryptoBalance: '0', + }; + const result = computeBalanceSnapshotComputed(stored, BTC_CREDS); + expect(result.avgCostFiatPerUnit).toBe(0); + }); + + it('computes balanceDeltaAtomic from prevSnapshot', () => { + const prevStored: BalanceSnapshotStored = { + ...baseStored, + cryptoBalance: '50000000', // 0.5 BTC + }; + const result = computeBalanceSnapshotComputed( + baseStored, + BTC_CREDS, + prevStored, + ); + // 1 BTC - 0.5 BTC = +0.5 BTC = +50,000,000 satoshis + expect(result.balanceDeltaAtomic).toBe('50000000'); + }); + + it('uses 0n as prevAtomic when prevSnapshot is absent', () => { + const result = computeBalanceSnapshotComputed( + baseStored, + BTC_CREDS, + undefined, + ); + // 100000000 - 0 = 100000000 + expect(result.balanceDeltaAtomic).toBe('100000000'); + }); + + it('uses 0n as prevAtomic when prevSnapshot is null', () => { + const result = computeBalanceSnapshotComputed(baseStored, BTC_CREDS, null); + expect(result.balanceDeltaAtomic).toBe('100000000'); + }); + + it('sets fiatBalance to NaN when markRate is not finite', () => { + const stored: BalanceSnapshotStored = {...baseStored, markRate: NaN}; + const result = computeBalanceSnapshotComputed(stored, BTC_CREDS); + expect(Number.isNaN(result.fiatBalance)).toBe(true); + }); + + it('uses ETH decimals (18) for ETH credentials', () => { + const ethStored: BalanceSnapshotStored = { + ...baseStored, + chain: 'eth', + coin: 'eth', + assetId: 'eth:eth', + cryptoBalance: '1000000000000000000', // 1 ETH in wei + markRate: 2_000, + remainingCostBasisFiat: 1_500, + }; + const result = computeBalanceSnapshotComputed(ethStored, ETH_CREDS); + expect(result.fiatBalance).toBeCloseTo(2_000, 2); + expect(result.avgCostFiatPerUnit).toBeCloseTo(1_500, 2); + }); + + it('spreads all stored fields through to the computed result', () => { + const result = computeBalanceSnapshotComputed(baseStored, BTC_CREDS); + expect(result.id).toBe(baseStored.id); + expect(result.walletId).toBe(baseStored.walletId); + expect(result.quoteCurrency).toBe(baseStored.quoteCurrency); + }); +}); + +// ─── buildBalanceSnapshots – basic happy paths ──────────────────────────────── + +describe('buildBalanceSnapshots – no txs', () => { + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('returns an empty array when there are no txs', () => { + const result = buildBalanceSnapshots(makeArgs()); + expect(result).toEqual([]); + }); + + it('returns an empty array when txs is undefined (coerced to [])', () => { + const result = buildBalanceSnapshots(makeArgs({txs: undefined as any})); + expect(result).toEqual([]); + }); +}); + +describe('buildBalanceSnapshots – single received tx', () => { + const txTime = ts('2024-06-01T12:00:00Z'); // unix seconds + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('produces one snapshot for a single received tx', () => { + const args = makeArgs({ + txs: [receivedTx('txabc', 100_000_000 /* 1 BTC in satoshis */, txTime)], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(1); + }); + + it('snapshot id starts with "tx:" for non-compressed single tx', () => { + const args = makeArgs({ + txs: [receivedTx('txabc', 100_000_000, txTime)], + }); + const [snap] = buildBalanceSnapshots(args); + expect(snap.id).toBe('tx:txabc'); + }); + + it('snapshot has correct walletId, chain, coin, network', () => { + const args = makeArgs({ + wallet: makeSummary({ + walletId: 'mywallet', + chain: 'btc', + currencyAbbreviation: 'btc', + }), + txs: [receivedTx('txabc', 100_000_000, txTime)], + }); + const [snap] = buildBalanceSnapshots(args); + expect(snap.walletId).toBe('mywallet'); + expect(snap.chain).toBe('btc'); + expect(snap.coin).toBe('btc'); + expect(snap.network).toBe('livenet'); + }); + + it('snapshot cryptoBalance reflects the received amount', () => { + const args = makeArgs({ + txs: [receivedTx('txabc', 100_000_000, txTime)], + }); + const [snap] = buildBalanceSnapshots(args); + expect(snap.cryptoBalance).toBe('100000000'); + }); + + it('snapshot remainingCostBasisFiat is calculated from rate', () => { + mockGetNearestRate.mockReturnValue(50_000); + const args = makeArgs({ + txs: [receivedTx('txabc', 100_000_000, txTime)], // 1 BTC + }); + const [snap] = buildBalanceSnapshots(args); + // 1 BTC * $50k = $50k cost basis + expect(snap.remainingCostBasisFiat).toBeCloseTo(50_000, 2); + }); + + it('snapshot quoteCurrency matches uppercased input', () => { + const args = makeArgs({ + quoteCurrency: 'eur', + txs: [receivedTx('txabc', 100_000_000, txTime)], + }); + const [snap] = buildBalanceSnapshots(args); + expect(snap.quoteCurrency).toBe('EUR'); + }); + + it('snapshot markRate equals the rate returned by the lookup', () => { + mockGetNearestRate.mockReturnValue(42_000); + const args = makeArgs({ + txs: [receivedTx('txabc', 100_000_000, txTime)], + }); + const [snap] = buildBalanceSnapshots(args); + expect(snap.markRate).toBe(42_000); + }); +}); + +describe('buildBalanceSnapshots – sent tx after received', () => { + const time1 = ts('2024-06-01T10:00:00Z'); + const time2 = ts('2024-06-01T14:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('produces two snapshots for two txs', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx1', 200_000_000, time1), // 2 BTC in + sentTx('tx2', 50_000_000, 1_000, time2), // 0.5 BTC out + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + }); + + it('running balance decreases on sent tx', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx1', 200_000_000, time1), // 2 BTC + sentTx('tx2', 50_000_000, 1_000, time2), // 0.5 BTC out + 1000 sat fee + ], + }); + const result = buildBalanceSnapshots(args); + const first = result[0]; + const second = result[1]; + expect(BigInt(first.cryptoBalance)).toBeGreaterThan( + BigInt(second.cryptoBalance), + ); + }); + + it('deduplicates transactions with the same txid', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, time1), + receivedTx('tx1', 100_000_000, time1), // duplicate + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(1); + }); +}); + +describe('buildBalanceSnapshots – moved tx', () => { + const time1 = ts('2024-05-01T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('a moved tx only subtracts fees, not amount', () => { + // moved: no balance change for amount, only fee deducted + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, time1), + movedTx('tx2', 5_000, ts('2024-05-02T10:00:00Z')), + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + // After receive, balance = 100_000_000 + // After moved (fee=5000, no amount change), balance = 100_000_000 - 5000 + const finalBal = BigInt(result[1].cryptoBalance); + expect(finalBal).toBe(100_000_000n - 5_000n); + }); +}); + +describe('buildBalanceSnapshots – failed EVM tx', () => { + const time1 = ts('2024-05-01T10:00:00Z'); + const time2 = ts('2024-05-02T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('failed tx with status=false only deducts fees', () => { + const args = makeArgs({ + wallet: makeSummary({ + chain: 'eth', + currencyAbbreviation: 'eth', + network: 'livenet', + }), + credentials: ETH_CREDS, + txs: [ + // receive 1 ETH first + { + txid: 'tx1', + action: 'received', + amount: 1_000_000_000_000_000_000n, + time: time1, + from: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + to: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + receipt: { + status: true, + gasUsed: '21000', + effectiveGasPrice: '1000000000', + }, + }, + // failed send - amount NOT deducted, only fee + { + txid: 'tx2', + action: 'sent', + amount: 500_000_000_000_000_000n, + time: time2, + from: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + receipt: { + status: false, // REVERTED + gasUsed: '21000', + effectiveGasPrice: '2000000000', + }, + }, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + // For a failed tx, amount is not transferred + // After first: balance = 1e18 (minus 21000 * 1e9 gas) + // After second (failed): no value transfer, just fee + const finalBal = BigInt(result[1].cryptoBalance); + const firstBal = BigInt(result[0].cryptoBalance); + // The failed tx should NOT have moved 0.5 ETH away + expect(finalBal).toBeGreaterThan(firstBal / 2n); + }); + + it('failed tx with status=0 (numeric) is recognized as failed', () => { + const args = makeArgs({ + wallet: makeSummary({chain: 'eth', currencyAbbreviation: 'eth'}), + credentials: ETH_CREDS, + txs: [ + receivedTx('tx1', 1_000_000_000_000_000_000n as any, time1), + { + txid: 'tx2', + action: 'sent', + amount: 500_000_000_000_000_000n, + time: time2, + receipt: { + status: 0, + gasUsed: '21000', + effectiveGasPrice: '1000000000', + }, + }, + ], + }); + // Should not throw; just run the simulation + expect(() => buildBalanceSnapshots(args)).not.toThrow(); + }); + + it('failed tx with status="0x0" is recognized as failed', () => { + const args = makeArgs({ + wallet: makeSummary({chain: 'eth', currencyAbbreviation: 'eth'}), + credentials: ETH_CREDS, + txs: [ + receivedTx('tx1', 1_000_000_000_000_000_000n as any, time1), + { + txid: 'tx2', + action: 'sent', + amount: 500_000_000_000_000_000n, + time: time2, + receipt: { + status: '0x0', + gasUsed: '21000', + effectiveGasPrice: '1000000000', + }, + }, + ], + }); + expect(() => buildBalanceSnapshots(args)).not.toThrow(); + }); +}); + +describe('buildBalanceSnapshots – token wallet (applyFeesToBalance=false)', () => { + const time1 = ts('2024-05-01T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(1); // $1 per USDC + }); + + it('does not apply fees to balance for a token wallet', () => { + const tokenWallet = makeSummary({ + chain: 'eth', + currencyAbbreviation: 'usdc', + tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }); + const args = makeArgs({ + wallet: tokenWallet, + credentials: {chain: 'eth', coin: 'usdc', token: {decimals: 6}}, + txs: [ + { + txid: 'tx1', + action: 'received', + amount: 1_000_000, // 1 USDC (6 decimals) + time: time1, + fees: 21_000_000_000_000, // large fee in ETH wei — should be ignored + }, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(1); + // Balance should be 1_000_000 (fees not applied) + expect(result[0].cryptoBalance).toBe('1000000'); + }); +}); + +describe('buildBalanceSnapshots – latestSnapshot cursor', () => { + const time1 = ts('2024-01-01T00:00:00Z'); + const time2 = ts('2024-01-02T00:00:00Z'); + const time3 = ts('2024-01-03T00:00:00Z'); + + const latestSnapshot: BalanceSnapshotStored = { + id: 'tx:tx1', + walletId: 'test-wallet', + chain: 'btc', + coin: 'btc', + network: 'livenet', + assetId: 'btc:btc', + timestamp: time1 * 1000, + eventType: 'tx', + cryptoBalance: '100000000', + remainingCostBasisFiat: 50_000, + quoteCurrency: 'USD', + markRate: RATE, + }; + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('skips txs at or before the latest snapshot timestamp when cursor found by txid', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, time1), + receivedTx('tx2', 50_000_000, time2), + receivedTx('tx3', 25_000_000, time3), + ], + latestSnapshot, + }); + const result = buildBalanceSnapshots(args); + // tx1 is in the latest snapshot; only tx2 and tx3 should be processed + expect(result).toHaveLength(2); + }); + + it('starts balance from latestSnapshot cryptoBalance', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, time1), + receivedTx('tx2', 50_000_000, time2), + ], + latestSnapshot, + }); + const result = buildBalanceSnapshots(args); + // Starting from 100_000_000 + 50_000_000 = 150_000_000 + expect(result[0].cryptoBalance).toBe('150000000'); + }); + + it('falls back to timestamp filtering when txid is not found in history', () => { + const snapshotWithUnknownTx: BalanceSnapshotStored = { + ...latestSnapshot, + id: 'tx:unknown_tx_not_in_list', + timestamp: time2 * 1000, // after time2 + }; + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, time1), + receivedTx('tx2', 50_000_000, time2), + receivedTx('tx3', 25_000_000, time3), + ], + latestSnapshot: snapshotWithUnknownTx, + }); + const result = buildBalanceSnapshots(args); + // Only txs STRICTLY after time2 should be processed → tx3 + expect(result).toHaveLength(1); + expect(result[0].id).toBe('tx:tx3'); + }); +}); + +describe('buildBalanceSnapshots – daily compression', () => { + // Use dates older than 90 days + const oldTime1 = ts('2020-01-01T10:00:00Z'); + const oldTime2 = ts('2020-01-01T14:00:00Z'); // same UTC day + const oldTime3 = ts('2020-01-02T10:00:00Z'); // next day + const nowMs = Date.now(); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('collapses multiple txs on the same day into a single daily snapshot', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, oldTime1), + receivedTx('tx2', 50_000_000, oldTime2), + receivedTx('tx3', 25_000_000, oldTime3), + ], + compression: {enabled: true}, + nowMs, + }); + const result = buildBalanceSnapshots(args); + // tx1+tx2 are on the same day → 1 daily snapshot; tx3 is alone on the next day → 1 tx snapshot + expect(result).toHaveLength(2); + expect(result[0].eventType).toBe('daily'); + // A single tx on a compressed day becomes a 'tx' eventType (not 'daily') + expect(result[1].eventType).toBe('tx'); + }); + + it('a single tx on a day becomes a tx snapshot (not daily)', () => { + const args = makeArgs({ + txs: [receivedTx('tx1', 100_000_000, oldTime1)], + compression: {enabled: true}, + nowMs, + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(1); + expect(result[0].eventType).toBe('tx'); + }); + + it('includes txIds for daily snapshots', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, oldTime1), + receivedTx('tx2', 50_000_000, oldTime2), + ], + compression: {enabled: true}, + nowMs, + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(1); + expect(result[0].txIds).toEqual(['tx1', 'tx2']); + }); + + it('daily snapshot id has format "daily:YYYY-MM-DD"', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, oldTime1), + receivedTx('tx2', 50_000_000, oldTime2), + ], + compression: {enabled: true}, + nowMs, + }); + const [snap] = buildBalanceSnapshots(args); + expect(snap.id).toMatch(/^daily:\d{4}-\d{2}-\d{2}$/); + }); + + it('no compression when compression.enabled=false', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, oldTime1), + receivedTx('tx2', 50_000_000, oldTime2), + ], + compression: {enabled: false}, + nowMs, + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + expect(result.every(s => s.eventType === 'tx')).toBe(true); + }); +}); + +describe('buildBalanceSnapshots – fee override reconciliation', () => { + // When we set wallet.balanceAtomic, the engine may adjust maxFee estimates. + const time1 = ts('2024-03-01T10:00:00Z'); + const time2 = ts('2024-03-02T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('triggers fee reduction when balanceAtomic > computed end balance and gasLimit * gasPrice equals reported fees', () => { + // Create a scenario where fees is gasLimit * gasPrice (the over-estimation pattern) + const gasLimit = 21_000; + const gasPrice = 2_000_000_000; // 2 gwei + const overEstimatedFee = gasLimit * gasPrice; // = 42_000_000_000 + + const startBalance = '1000000000000000000'; // 1 ETH + // If fees were actually less, the end balance would be higher + const trueEndBalance = String( + BigInt(startBalance) - BigInt(overEstimatedFee / 2), + ); + + const args = makeArgs({ + wallet: { + ...makeSummary({chain: 'eth', currencyAbbreviation: 'eth'}), + balanceAtomic: trueEndBalance, // anchor balance is higher than computed + } as any, + credentials: ETH_CREDS, + txs: [ + { + txid: 'tx1', + action: 'sent', + amount: 0, + fees: overEstimatedFee, + time: time1, + gasLimit: gasLimit.toString(), + gasPrice: gasPrice.toString(), + }, + ], + }); + + // Should not throw; fee override path exercised + expect(() => buildBalanceSnapshots(args)).not.toThrow(); + }); + + it('no fee override when applyFeesToBalance is false (token wallet)', () => { + const args = makeArgs({ + wallet: { + ...makeSummary({ + chain: 'eth', + currencyAbbreviation: 'usdt', + tokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', + }), + balanceAtomic: '1000000', // anchor + } as any, + credentials: {chain: 'eth', coin: 'usdt', token: {decimals: 6}}, + txs: [receivedTx('tx1', 1_000_000, time1)], + }); + // Token wallets skip fee override computation; should just return first.out + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(1); + }); +}); + +describe('buildBalanceSnapshots – tx ordering / underflow prevention', () => { + // Two txs with the same timestamp: a sent and a received. + // If sent comes first, balance could go negative. + const sameTime = ts('2024-04-01T12:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('reorders same-timestamp txs to prevent balance underflow', () => { + // Receive 1 BTC and send 0.5 BTC — both at the same time. + // If sent is processed before received, balance underflows (starts at 0). + const args = makeArgs({ + txs: [ + sentTx('tx_send', 50_000_000, 1_000, sameTime), // processed 2nd + receivedTx('tx_recv', 100_000_000, sameTime), // should be processed 1st + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + // End balance should be: 100_000_000 (recv) - 50_000_000 (sent) - 1_000 (fee) = 49_999_000 + const finalBal = BigInt(result[result.length - 1].cryptoBalance); + expect(finalBal).toBe(49_999_000n); + }); +}); + +describe('buildBalanceSnapshots – unknown action tx', () => { + const time1 = ts('2024-04-01T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('positive rawAmount on unknown action creates an inflow', () => { + const args = makeArgs({ + txs: [{txid: 'tx1', action: 'reward', amount: 50_000_000, time: time1}], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(1); + expect(result[0].cryptoBalance).toBe('50000000'); + }); + + it('negative rawAmount on unknown action creates an outflow', () => { + // First receive some funds + const time0 = ts('2024-04-01T09:00:00Z'); + const args = makeArgs({ + txs: [ + receivedTx('tx0', 100_000_000, time0), + {txid: 'tx1', action: 'slash', amount: -30_000_000, time: time1}, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + const finalBal = BigInt(result[1].cryptoBalance); + expect(finalBal).toBe(70_000_000n); + }); + + it('zero amount unknown action with fees creates fee-only outflow', () => { + const time0 = ts('2024-04-01T09:00:00Z'); + const args = makeArgs({ + txs: [ + receivedTx('tx0', 100_000_000, time0), + {txid: 'tx1', action: 'contract', amount: 0, fees: 5_000, time: time1}, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + const finalBal = BigInt(result[1].cryptoBalance); + expect(finalBal).toBe(100_000_000n - 5_000n); + }); + + it('zero amount unknown action with zero fees is a no-op', () => { + const time0 = ts('2024-04-01T09:00:00Z'); + const args = makeArgs({ + txs: [ + receivedTx('tx0', 100_000_000, time0), + {txid: 'tx1', action: 'unknown', amount: 0, fees: 0, time: time1}, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + expect(result[1].cryptoBalance).toBe('100000000'); + }); +}); + +describe('buildBalanceSnapshots – EVM fee handling', () => { + const time1 = ts('2024-04-01T10:00:00Z'); + const time2 = ts('2024-04-02T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(2_000); + }); + + it('uses receipt gasUsed * effectiveGasPrice when present', () => { + const gasUsed = 21_000; + const gasPrice = 5_000_000_000; // 5 gwei + const expectedFee = BigInt(gasUsed) * BigInt(gasPrice); + + const args = makeArgs({ + wallet: makeSummary({chain: 'eth', currencyAbbreviation: 'eth'}), + credentials: ETH_CREDS, + txs: [ + receivedTx('tx0', 1_000_000_000_000_000_000n as any, time1), + { + txid: 'tx1', + action: 'sent', + amount: 500_000_000_000_000_000n, + time: time2, + from: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + to: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + fees: 999_999_999, // different from actual gas cost — should be overridden + receipt: { + from: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + gasUsed: gasUsed.toString(), + effectiveGasPrice: gasPrice.toString(), + }, + }, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + // After send: 1e18 - 5e17 - fee + const finalBal = BigInt(result[1].cryptoBalance); + const expectedBal = + 1_000_000_000_000_000_000n - 500_000_000_000_000_000n - expectedFee; + // Close enough — the mock may collapse small precision differences + expect(finalBal).toBe(expectedBal < 0n ? 0n : expectedBal); + }); + + it('falls back to fees field when receipt has no gasUsed', () => { + const args = makeArgs({ + wallet: makeSummary({chain: 'eth', currencyAbbreviation: 'eth'}), + credentials: ETH_CREDS, + txs: [ + receivedTx('tx0', 1_000_000_000_000_000_000n as any, time1), + { + txid: 'tx1', + action: 'sent', + amount: 100_000_000_000_000_000n, + time: time2, + fees: 42_000_000_000_000, // fallback fee field + }, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + }); + + it('includes L1 data fee for OP Stack chains', () => { + const gasUsed = 21_000; + const effectiveGasPrice = 1_000_000; // 1 gwei (low) + const l1Fee = 500_000_000_000; // 500 gwei equivalent + + const args = makeArgs({ + wallet: makeSummary({chain: 'base', currencyAbbreviation: 'eth'}), + credentials: {chain: 'base', coin: 'eth'}, + txs: [ + { + txid: 'tx0', + action: 'received', + amount: 1_000_000_000_000_000_000n, + time: time1, + }, + { + txid: 'tx1', + action: 'sent', + amount: 100_000_000_000_000_000n, + time: time2, + from: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + receipt: { + from: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + gasUsed: gasUsed.toString(), + effectiveGasPrice: effectiveGasPrice.toString(), + l1Fee: l1Fee.toString(), + }, + }, + ], + }); + expect(() => buildBalanceSnapshots(args)).not.toThrow(); + }); +}); + +describe('buildBalanceSnapshots – received tx with amount=0 and fees', () => { + const time1 = ts('2024-05-01T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('treats received tx with amount=0 and fees>0 as fee-only outflow', () => { + const args = makeArgs({ + txs: [ + receivedTx('tx0', 100_000_000, ts('2024-05-01T09:00:00Z')), + { + txid: 'tx1', + action: 'received', + amount: 0, + fees: 1_000, + time: time1, + }, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + // fee-only: balance reduced by fee + const finalBal = BigInt(result[1].cryptoBalance); + expect(finalBal).toBe(100_000_000n - 1_000n); + }); +}); + +describe('buildBalanceSnapshots – hex amount strings', () => { + const time1 = ts('2024-04-15T12:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(1); + }); + + it('handles hex amount strings via parseNumberishToBigint path', () => { + const args = makeArgs({ + txs: [ + {txid: 'tx1', action: 'received', amount: '0x5f5e100', time: time1}, + ], + }); + // 0x5f5e100 = 100_000_000 in decimal + expect(() => buildBalanceSnapshots(args)).not.toThrow(); + }); +}); + +describe('buildBalanceSnapshots – tx sort order', () => { + const base = ts('2024-06-01T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('sorts txs by blockHeight when timestamps are equal', () => { + const args = makeArgs({ + txs: [ + {...receivedTx('tx1', 100_000_000, base), blockheight: 200}, + {...receivedTx('tx2', 50_000_000, base), blockheight: 100}, // earlier block + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + // tx2 (block 100) processed first → 50_000_000 + // tx1 (block 200) processed second → 150_000_000 + expect(result[0].cryptoBalance).toBe('50000000'); + expect(result[1].cryptoBalance).toBe('150000000'); + }); + + it('sorts txs by transactionIndex when block and timestamp are equal', () => { + const args = makeArgs({ + txs: [ + { + ...receivedTx('tx1', 100_000_000, base), + blockheight: 100, + receipt: {transactionIndex: 5}, + }, + { + ...receivedTx('tx2', 50_000_000, base), + blockheight: 100, + receipt: {transactionIndex: 2}, + }, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + // tx2 (txIndex=2) first, tx1 (txIndex=5) second + expect(result[0].cryptoBalance).toBe('50000000'); + expect(result[1].cryptoBalance).toBe('150000000'); + }); +}); + +// ─── buildBalanceSnapshotsAsync ─────────────────────────────────────────────── + +describe('buildBalanceSnapshotsAsync', () => { + const time1 = ts('2024-06-01T12:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('returns the same results as the sync version for a simple case', async () => { + const fixedNowMs = 1_700_000_000_000; + const args = makeArgs({ + txs: [receivedTx('txabc', 100_000_000, time1)], + nowMs: fixedNowMs, + }); + const syncResult = buildBalanceSnapshots(args); + const asyncResult = await buildBalanceSnapshotsAsync(args); + expect(asyncResult).toEqual(syncResult); + }); + + it('returns empty array when there are no txs', async () => { + const result = await buildBalanceSnapshotsAsync(makeArgs()); + expect(result).toEqual([]); + }); + + it('handles yieldEvery option without error', async () => { + const txs = Array.from({length: 10}, (_, i) => + receivedTx(`tx${i}`, 1_000_000, ts('2024-06-01T12:00:00Z') + i), + ); + const args = makeArgs({txs}); + const result = await buildBalanceSnapshotsAsync(args, {yieldEvery: 3}); + expect(result).toHaveLength(10); + }); + + it('invokes onYield callback during processing', async () => { + const onYield = jest.fn().mockResolvedValue(undefined); + const txs = Array.from({length: 5}, (_, i) => + receivedTx(`tx${i}`, 1_000_000, ts('2024-06-01T12:00:00Z') + i), + ); + const args = makeArgs({txs}); + await buildBalanceSnapshotsAsync(args, {yieldEvery: 2, onYield}); + expect(onYield).toHaveBeenCalled(); + }); + + it('respects compression option', async () => { + const nowMs = Date.now(); + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, ts('2020-01-01T10:00:00Z')), + receivedTx('tx2', 50_000_000, ts('2020-01-01T14:00:00Z')), + ], + compression: {enabled: true}, + nowMs, + }); + const result = await buildBalanceSnapshotsAsync(args); + expect(result).toHaveLength(1); + expect(result[0].eventType).toBe('daily'); + }); + + it('applies fee overrides in async path when needed', async () => { + const gasLimit = 21_000; + const gasPrice = 2_000_000_000; + const overEstimatedFee = gasLimit * gasPrice; + const startBalance = '1000000000000000000'; + const trueEndBalance = String( + BigInt(startBalance) - BigInt(overEstimatedFee / 2), + ); + + const args = makeArgs({ + wallet: { + ...makeSummary({chain: 'eth', currencyAbbreviation: 'eth'}), + balanceAtomic: trueEndBalance, + } as any, + credentials: ETH_CREDS, + txs: [ + { + txid: 'tx1', + action: 'sent', + amount: 0, + fees: overEstimatedFee, + time: ts('2024-04-01T10:00:00Z'), + gasLimit: gasLimit.toString(), + gasPrice: gasPrice.toString(), + }, + ], + }); + + await expect(buildBalanceSnapshotsAsync(args)).resolves.not.toThrow(); + }); +}); + +// ─── isTxFailed edge cases via buildBalanceSnapshots ───────────────────────── + +describe('isTxFailed – edge cases via buildBalanceSnapshots', () => { + const time1 = ts('2024-04-01T10:00:00Z'); + const time2 = ts('2024-04-02T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + function buildWithStatus(status: any) { + return makeArgs({ + wallet: makeSummary({chain: 'eth', currencyAbbreviation: 'eth'}), + credentials: ETH_CREDS, + txs: [ + receivedTx('tx0', 1_000_000_000_000_000_000n as any, time1), + { + txid: 'tx1', + action: 'sent', + amount: 500_000_000_000_000_000n, + time: time2, + receipt: {status, gasUsed: '21000', effectiveGasPrice: '1000000000'}, + }, + ], + }); + } + + it('status=true → not failed → amount IS deducted', () => { + const result = buildBalanceSnapshots(buildWithStatus(true)); + const finalBal = BigInt(result[1].cryptoBalance); + // amount + fee deducted + expect(finalBal).toBeLessThan(1_000_000_000_000_000_000n); + }); + + it('status=false → failed → amount NOT deducted', () => { + const result = buildBalanceSnapshots(buildWithStatus(false)); + const firstBal = BigInt(result[0].cryptoBalance); + const finalBal = BigInt(result[1].cryptoBalance); + // Amount should not be deducted; only fee + expect(finalBal).toBeGreaterThan(firstBal / 2n); + }); + + it('status="false" string → failed', () => { + const result = buildBalanceSnapshots(buildWithStatus('false')); + const firstBal = BigInt(result[0].cryptoBalance); + const finalBal = BigInt(result[1].cryptoBalance); + expect(finalBal).toBeGreaterThan(firstBal / 2n); + }); + + it('status="0x1" → not failed', () => { + const result = buildBalanceSnapshots(buildWithStatus('0x1')); + const finalBal = BigInt(result[1].cryptoBalance); + expect(finalBal).toBeLessThan(1_000_000_000_000_000_000n); + }); + + it('status=null → not failed', () => { + const result = buildBalanceSnapshots(buildWithStatus(null)); + // null status means no receipt status → treat as not failed + expect(result).toHaveLength(2); + }); + + it('status=1n (bigint) → not failed', () => { + const result = buildBalanceSnapshots(buildWithStatus(1n)); + expect(result).toHaveLength(2); + const finalBal = BigInt(result[1].cryptoBalance); + expect(finalBal).toBeLessThan(1_000_000_000_000_000_000n); + }); + + it('status=0n (bigint) → failed', () => { + const result = buildBalanceSnapshots(buildWithStatus(0n)); + const firstBal = BigInt(result[0].cryptoBalance); + const finalBal = BigInt(result[1].cryptoBalance); + expect(finalBal).toBeGreaterThan(firstBal / 2n); + }); +}); + +// ─── EVM address inference (inferWalletEvmAddresses) via buildBalanceSnapshots ─ + +describe('EVM address inference via buildBalanceSnapshots', () => { + const time1 = ts('2024-05-01T10:00:00Z'); + const time2 = ts('2024-05-02T10:00:00Z'); + const myAddr = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const otherAddr = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(2_000); + }); + + it('infers wallet address from sent tx "from" field and applies fee', () => { + const args = makeArgs({ + wallet: makeSummary({chain: 'eth', currencyAbbreviation: 'eth'}), + credentials: ETH_CREDS, + txs: [ + { + txid: 'tx0', + action: 'received', + amount: 1_000_000_000_000_000_000n, + time: time1, + from: otherAddr, + to: myAddr, + receipt: { + from: otherAddr, + to: myAddr, + gasUsed: '21000', + effectiveGasPrice: '1000000000', + status: true, + }, + }, + { + txid: 'tx1', + action: 'sent', + amount: 100_000_000_000_000_000n, + time: time2, + from: myAddr, + to: otherAddr, + receipt: { + from: myAddr, + to: otherAddr, + gasUsed: '21000', + effectiveGasPrice: '2000000000', + status: true, + }, + }, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + // Fee should be applied since wallet address (myAddr) matches tx.from + const feeApplied = 21_000n * 2_000_000_000n; + const expectedFinal = + 1_000_000_000_000_000_000n - 100_000_000_000_000_000n - feeApplied; + expect(BigInt(result[1].cryptoBalance)).toBe(expectedFinal); + }); + + it('does not apply fee when tx.from is an unknown external address', () => { + const externalSender = '0xcccccccccccccccccccccccccccccccccccccccc'; + const args = makeArgs({ + wallet: makeSummary({chain: 'eth', currencyAbbreviation: 'eth'}), + credentials: ETH_CREDS, + txs: [ + { + txid: 'tx0', + action: 'received', + amount: 1_000_000_000_000_000_000n, + time: time1, + from: myAddr, + to: myAddr, + receipt: { + from: myAddr, + gasUsed: '21000', + effectiveGasPrice: '1000000000', + status: true, + }, + }, + // received tx from an external address — their fee, not ours + { + txid: 'tx1', + action: 'received', + amount: 0, + time: time2, + from: externalSender, + receipt: { + from: externalSender, + gasUsed: '21000', + effectiveGasPrice: '1000000000', + status: true, + }, + }, + ], + }); + const result = buildBalanceSnapshots(args); + expect(result).toHaveLength(2); + }); +}); + +// ─── latestSnapshot with daily eventType ───────────────────────────────────── + +describe('buildBalanceSnapshots – latestSnapshot with daily eventType', () => { + const time1 = ts('2024-01-01T10:00:00Z'); + const time2 = ts('2024-01-02T10:00:00Z'); + const time3 = ts('2024-01-03T10:00:00Z'); + + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('uses txIds from daily snapshot as cursor', () => { + const dailySnapshot: BalanceSnapshotStored = { + id: 'daily:2024-01-01', + walletId: 'test-wallet', + chain: 'btc', + coin: 'btc', + network: 'livenet', + assetId: 'btc:btc', + timestamp: time1 * 1000, + eventType: 'daily', + txIds: ['tx1', 'tx2'], + cryptoBalance: '150000000', + remainingCostBasisFiat: 75_000, + quoteCurrency: 'USD', + markRate: RATE, + }; + + const args = makeArgs({ + txs: [ + receivedTx('tx1', 100_000_000, time1), + receivedTx('tx2', 50_000_000, time1), + receivedTx('tx3', 25_000_000, time3), + ], + latestSnapshot: dailySnapshot, + }); + const result = buildBalanceSnapshots(args); + // tx1 and tx2 are in the snapshot → only tx3 should be processed + expect(result).toHaveLength(1); + expect(result[0].id).toBe('tx:tx3'); + }); +}); + +// ─── onProgress callback ────────────────────────────────────────────────────── + +describe('buildBalanceSnapshots – onProgress callback', () => { + beforeEach(() => { + mockGetNearestRate.mockReturnValue(RATE); + }); + + it('calls onProgress when total txs is a multiple of 250', () => { + const onProgress = jest.fn(); + const txs = Array.from({length: 250}, (_, i) => + receivedTx(`tx${i}`, 1_000_000, ts('2024-06-01T12:00:00Z') + i), + ); + buildBalanceSnapshots(makeArgs({txs, onProgress})); + expect(onProgress).toHaveBeenCalledWith({processed: 250, total: 250}); + }); + + it('calls onProgress at the last tx when processed < 250', () => { + const onProgress = jest.fn(); + const txs = [receivedTx('tx0', 100_000_000, ts('2024-06-01T12:00:00Z'))]; + buildBalanceSnapshots(makeArgs({txs, onProgress})); + expect(onProgress).toHaveBeenCalledWith({processed: 1, total: 1}); + }); +}); diff --git a/src/utils/portfolio/rate.spec.ts b/src/utils/portfolio/rate.spec.ts new file mode 100644 index 0000000000..9289e801a5 --- /dev/null +++ b/src/utils/portfolio/rate.spec.ts @@ -0,0 +1,845 @@ +/** + * Tests for src/utils/portfolio/rate.ts + */ +import { + getFiatRateFromSeriesCacheAtTimestamp, + getWindowMsForFiatRateTimeframe, + getFiatRateBaselineTsForTimeframe, + getFiatRateSeriesIntervalForTimeframe, + getFiatRateTimeframeConfig, + getFiatRateChangeForTimeframe, + alignTimestamps, + downsampleSeries, + downsampleTimestamps, + trimTimestamps, +} from './rate'; +import type { + RatePoint, + RatesByCoin, + AlignedRatesByCoin, + FiatRateTimeframeConfig, + FiatRateChangeForTimeframe, +} from './rate'; +import type { + FiatRateSeriesCache, + FiatRateInterval, + FiatRatePoint, +} from '../../store/rate/rate.models'; +import {getFiatRateSeriesCacheKey} from '../../store/rate/rate.models'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const MS_PER_HOUR = 60 * 60 * 1000; +const MS_PER_DAY = 24 * MS_PER_HOUR; + +/** Build a FiatRateSeriesCache with a simple ascending rate series. */ +function makeCache( + fiatCode: string, + coin: string, + interval: FiatRateInterval, + points: FiatRatePoint[], +): FiatRateSeriesCache { + const key = getFiatRateSeriesCacheKey(fiatCode, coin, interval); + return {[key]: {fetchedOn: Date.now(), points}}; +} + +/** Build N evenly spaced rate points starting from baseTs with a given step. */ +function makePoints( + n: number, + baseTs: number, + stepMs: number, + startRate: number = 100, + rateStep: number = 1, +): FiatRatePoint[] { + return Array.from({length: n}, (_, i) => ({ + ts: baseTs + i * stepMs, + rate: startRate + i * rateStep, + })); +} + +/** Build RatePoint[] (ts + rate) for alignTimestamps tests. */ +function makeRatePoints( + n: number, + baseTs: number, + stepMs: number, + startRate: number = 1, +): RatePoint[] { + return Array.from({length: n}, (_, i) => ({ + ts: baseTs + i * stepMs, + rate: startRate + i, + })); +} + +// ─── getWindowMsForFiatRateTimeframe ───────────────────────────────────────── + +describe('getWindowMsForFiatRateTimeframe', () => { + it('returns 1 day for 1D', () => { + expect(getWindowMsForFiatRateTimeframe('1D')).toBe(1 * MS_PER_DAY); + }); + + it('returns 7 days for 1W', () => { + expect(getWindowMsForFiatRateTimeframe('1W')).toBe(7 * MS_PER_DAY); + }); + + it('returns 30 days for 1M', () => { + expect(getWindowMsForFiatRateTimeframe('1M')).toBe(30 * MS_PER_DAY); + }); + + it('returns 90 days for 3M', () => { + expect(getWindowMsForFiatRateTimeframe('3M')).toBe(90 * MS_PER_DAY); + }); + + it('returns 365 days for 1Y', () => { + expect(getWindowMsForFiatRateTimeframe('1Y')).toBe(365 * MS_PER_DAY); + }); + + it('returns 1825 days for 5Y', () => { + expect(getWindowMsForFiatRateTimeframe('5Y')).toBe(1825 * MS_PER_DAY); + }); + + it('returns 0 for ALL (no fixed window)', () => { + expect(getWindowMsForFiatRateTimeframe('ALL')).toBe(0); + }); +}); + +// ─── getFiatRateSeriesIntervalForTimeframe ──────────────────────────────────── + +describe('getFiatRateSeriesIntervalForTimeframe', () => { + it('maps 3M to ALL (uses full series)', () => { + expect(getFiatRateSeriesIntervalForTimeframe('3M')).toBe('ALL'); + }); + + it('maps 1Y to ALL', () => { + expect(getFiatRateSeriesIntervalForTimeframe('1Y')).toBe('ALL'); + }); + + it('maps 5Y to ALL', () => { + expect(getFiatRateSeriesIntervalForTimeframe('5Y')).toBe('ALL'); + }); + + it('returns 1D for 1D', () => { + expect(getFiatRateSeriesIntervalForTimeframe('1D')).toBe('1D'); + }); + + it('returns 1W for 1W', () => { + expect(getFiatRateSeriesIntervalForTimeframe('1W')).toBe('1W'); + }); + + it('returns 1M for 1M', () => { + expect(getFiatRateSeriesIntervalForTimeframe('1M')).toBe('1M'); + }); + + it('returns ALL for ALL', () => { + expect(getFiatRateSeriesIntervalForTimeframe('ALL')).toBe('ALL'); + }); +}); + +// ─── getFiatRateBaselineTsForTimeframe ──────────────────────────────────────── + +describe('getFiatRateBaselineTsForTimeframe', () => { + const nowMs = new Date('2024-06-15T12:30:00Z').getTime(); + + it('returns undefined for ALL timeframe', () => { + expect( + getFiatRateBaselineTsForTimeframe({timeframe: 'ALL', nowMs}), + ).toBeUndefined(); + }); + + it('returns 24h ago (rounded down to hour) for 1D', () => { + const result = getFiatRateBaselineTsForTimeframe({ + timeframe: '1D', + nowMs, + }); + // Must be exactly 24h behind nowMs, truncated to the hour + const lastDay = nowMs - MS_PER_DAY; + const expected = Math.floor(lastDay / MS_PER_HOUR) * MS_PER_HOUR; + expect(result).toBe(expected); + }); + + it('returns 7 days ago (rounded down to hour) for 1W', () => { + const result = getFiatRateBaselineTsForTimeframe({ + timeframe: '1W', + nowMs, + }); + const windowMs = 7 * MS_PER_DAY; + const expected = Math.floor((nowMs - windowMs) / MS_PER_HOUR) * MS_PER_HOUR; + expect(result).toBe(expected); + }); + + it('returns 30 days ago for 1M', () => { + const result = getFiatRateBaselineTsForTimeframe({ + timeframe: '1M', + nowMs, + }); + const windowMs = 30 * MS_PER_DAY; + const expected = Math.floor((nowMs - windowMs) / MS_PER_HOUR) * MS_PER_HOUR; + expect(result).toBe(expected); + }); + + it('returns 90 days ago for 3M', () => { + const result = getFiatRateBaselineTsForTimeframe({ + timeframe: '3M', + nowMs, + }); + const windowMs = 90 * MS_PER_DAY; + const expected = Math.floor((nowMs - windowMs) / MS_PER_HOUR) * MS_PER_HOUR; + expect(result).toBe(expected); + }); + + it('uses Date.now() when nowMs is omitted', () => { + const before = Date.now(); + const result = getFiatRateBaselineTsForTimeframe({timeframe: '1W'}); + const after = Date.now(); + expect(result).toBeGreaterThanOrEqual( + Math.floor((before - 7 * MS_PER_DAY) / MS_PER_HOUR) * MS_PER_HOUR, + ); + expect(result).toBeLessThanOrEqual( + Math.floor((after - 7 * MS_PER_DAY) / MS_PER_HOUR) * MS_PER_HOUR + + MS_PER_HOUR, + ); + }); +}); + +// ─── getFiatRateTimeframeConfig ─────────────────────────────────────────────── + +describe('getFiatRateTimeframeConfig', () => { + const nowMs = new Date('2024-01-01T00:00:00Z').getTime(); + + it('returns correct windowMs for 1D', () => { + const cfg = getFiatRateTimeframeConfig({timeframe: '1D', nowMs}); + expect(cfg.windowMs).toBe(MS_PER_DAY); + expect(cfg.seriesInterval).toBe('1D'); + expect(typeof cfg.baselineTimestampMs).toBe('number'); + }); + + it('returns windowMs 0 for ALL and undefined baseline', () => { + const cfg = getFiatRateTimeframeConfig({timeframe: 'ALL', nowMs}); + expect(cfg.windowMs).toBe(0); + expect(cfg.baselineTimestampMs).toBeUndefined(); + expect(cfg.seriesInterval).toBe('ALL'); + }); + + it('maps 3M seriesInterval to ALL', () => { + const cfg = getFiatRateTimeframeConfig({timeframe: '3M', nowMs}); + expect(cfg.seriesInterval).toBe('ALL'); + expect(cfg.windowMs).toBe(90 * MS_PER_DAY); + }); + + it('uses Date.now() when nowMs is not provided', () => { + const cfg = getFiatRateTimeframeConfig({timeframe: '1W'}); + expect(cfg.windowMs).toBe(7 * MS_PER_DAY); + expect(cfg.baselineTimestampMs).toBeDefined(); + }); +}); + +// ─── getFiatRateFromSeriesCacheAtTimestamp ──────────────────────────────────── + +describe('getFiatRateFromSeriesCacheAtTimestamp', () => { + const baseTs = 1_000_000; + const stepMs = 60_000; // 1 min intervals + const points = makePoints(10, baseTs, stepMs, 100, 10); + // rates: 100,110,120,...190 at baseTs, baseTs+1min, ... + + const cache = makeCache('USD', 'BTC', '1D', points); + + it('returns undefined when cache is undefined', () => { + const result = getFiatRateFromSeriesCacheAtTimestamp({ + fiatRateSeriesCache: undefined, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + interval: '1D', + timestampMs: baseTs, + }); + expect(result).toBeUndefined(); + }); + + it('returns nearest rate by default (no method specified)', () => { + const result = getFiatRateFromSeriesCacheAtTimestamp({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + interval: '1D', + timestampMs: baseTs, + }); + expect(result).toBe(100); + }); + + it('returns exact rate when ts matches exactly (nearest)', () => { + const result = getFiatRateFromSeriesCacheAtTimestamp({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + interval: '1D', + timestampMs: baseTs + 2 * stepMs, + }); + expect(result).toBe(120); + }); + + it('returns nearest point when ts is between two points (nearest)', () => { + // ts = baseTs + 1.4 * stepMs → closer to index 1 (110) than index 2 (120) + const result = getFiatRateFromSeriesCacheAtTimestamp({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + interval: '1D', + timestampMs: baseTs + Math.floor(1.4 * stepMs), + }); + expect(result).toBe(110); + }); + + it('returns linearly interpolated rate when method is linear', () => { + // midpoint between index 0 (100) and index 1 (110) → expect 105 + const midTs = baseTs + Math.floor(0.5 * stepMs); + const result = getFiatRateFromSeriesCacheAtTimestamp({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + interval: '1D', + timestampMs: midTs, + method: 'linear', + }); + expect(result).toBeCloseTo(105, 0); + }); + + it('returns undefined for a cache key that does not exist', () => { + const result = getFiatRateFromSeriesCacheAtTimestamp({ + fiatRateSeriesCache: cache, + fiatCode: 'EUR', // wrong fiat + currencyAbbreviation: 'BTC', + interval: '1D', + timestampMs: baseTs, + }); + expect(result).toBeUndefined(); + }); + + it('returns first rate when ts is before all points (nearest)', () => { + const result = getFiatRateFromSeriesCacheAtTimestamp({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + interval: '1D', + timestampMs: baseTs - 99999, + }); + expect(result).toBe(100); + }); + + it('returns last rate when ts is after all points (nearest)', () => { + const result = getFiatRateFromSeriesCacheAtTimestamp({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + interval: '1D', + timestampMs: baseTs + 100 * stepMs, + }); + expect(result).toBe(190); + }); + + it('handles MATIC coin normalization (MATIC → POL)', () => { + const maticPoints = makePoints(5, baseTs, stepMs, 200, 5); + // normalizeFiatRateSeriesCoin maps 'matic' → 'pol' + const maticCacheKey = getFiatRateSeriesCacheKey('USD', 'pol', '1D'); + const maticCache: FiatRateSeriesCache = { + [maticCacheKey]: {fetchedOn: Date.now(), points: maticPoints}, + }; + const result = getFiatRateFromSeriesCacheAtTimestamp({ + fiatRateSeriesCache: maticCache, + fiatCode: 'USD', + currencyAbbreviation: 'MATIC', + interval: '1D', + timestampMs: baseTs, + }); + expect(result).toBe(200); + }); +}); + +// ─── getFiatRateChangeForTimeframe ──────────────────────────────────────────── + +describe('getFiatRateChangeForTimeframe', () => { + const nowMs = 10_000_000; + // Create a simple series covering the full window for 1W + const windowMs = 7 * MS_PER_DAY; + // baseline is nowMs - windowMs (rounded to hour) + const baselineMs = Math.floor((nowMs - windowMs) / MS_PER_HOUR) * MS_PER_HOUR; + // Build points: one at baselineMs (rate=100) and one at nowMs (rate=150) + const points: FiatRatePoint[] = [ + {ts: baselineMs, rate: 100}, + {ts: nowMs, rate: 150}, + ]; + const cache = makeCache('USD', 'BTC', '1W', points); + + it('returns undefined when cache is undefined', () => { + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: undefined, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: '1W', + nowMs, + }); + expect(result).toBeUndefined(); + }); + + it('returns undefined when points are not in cache', () => { + // cache has 1W but we ask for EUR + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: cache, + fiatCode: 'EUR', + currencyAbbreviation: 'BTC', + timeframe: '1W', + nowMs, + }); + expect(result).toBeUndefined(); + }); + + it('computes correct priceChange and percentChange for 1W', () => { + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: '1W', + nowMs, + currentRate: 150, + }); + expect(result).not.toBeUndefined(); + expect(result!.priceChange).toBeCloseTo(50, 5); + expect(result!.percentChange).toBeCloseTo(50, 1); + expect(result!.percentRatio).toBeCloseTo(0.5, 5); + expect(result!.baselineRate).toBe(100); + expect(result!.currentRate).toBe(150); + expect(result!.timeframe).toBe('1W'); + }); + + it('uses provided currentRate instead of computing from series', () => { + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: '1W', + nowMs, + currentRate: 200, + }); + expect(result!.currentRate).toBe(200); + expect(result!.priceChange).toBeCloseTo(100, 5); + }); + + it('falls back to series for currentRate when not provided', () => { + // points at nowMs has rate=150 + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: '1W', + nowMs, + }); + expect(result).not.toBeUndefined(); + // nearest rate at nowMs is 150 + expect(result!.currentRate).toBe(150); + }); + + it('uses first point as baseline for ALL timeframe', () => { + const allPoints: FiatRatePoint[] = [ + {ts: 1000, rate: 50}, + {ts: 2000, rate: 100}, + {ts: nowMs, rate: 150}, + ]; + const allCache = makeCache('USD', 'BTC', 'ALL', allPoints); + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: allCache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: 'ALL', + nowMs, + currentRate: 150, + }); + expect(result).not.toBeUndefined(); + expect(result!.baselineRate).toBe(50); + expect(result!.baselineTimestampMs).toBe(1000); + expect(result!.priceChange).toBeCloseTo(100, 5); + expect(result!.percentChange).toBeCloseTo(200, 1); + }); + + it('returns undefined for ALL when first point rate is 0', () => { + const badPoints: FiatRatePoint[] = [ + {ts: 1000, rate: 0}, // invalid — rate must be > 0 + {ts: 2000, rate: 100}, + ]; + const badCache = makeCache('USD', 'BTC', 'ALL', badPoints); + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: badCache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: 'ALL', + nowMs, + currentRate: 100, + }); + expect(result).toBeUndefined(); + }); + + it('falls back to series lookup when currentRate is Infinity (not treated as valid)', () => { + // When currentRate is Infinity, Number.isFinite check fails → falls back to + // looking up currentRate from the series. The series has a point at nowMs=150, + // so a valid result is returned. + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: '1W', + nowMs, + currentRate: Infinity, + }); + // Falls back to nearest point at nowMs → 150 + expect(result).not.toBeUndefined(); + expect(result!.currentRate).toBe(150); + }); + + it('uses linear method by default', () => { + // With only 2 points, linear interpolation at baseline equals first point exactly + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: '1W', + nowMs, + currentRate: 150, + }); + expect(result).not.toBeUndefined(); + // baseline at baselineMs → exactly rate=100 (linear returns left.rate at exact point) + expect(result!.baselineRate).toBeCloseTo(100, 5); + }); + + it('supports nearest method via options', () => { + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: cache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: '1W', + nowMs, + currentRate: 150, + method: 'nearest', + }); + expect(result).not.toBeUndefined(); + expect(result!.baselineRate).toBe(100); + }); + + it('handles 3M which uses ALL series interval internally', () => { + const allPoints: FiatRatePoint[] = [ + {ts: 1000, rate: 80}, + {ts: nowMs, rate: 160}, + ]; + const allCache = makeCache('USD', 'BTC', 'ALL', allPoints); + const result = getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: allCache, + fiatCode: 'USD', + currencyAbbreviation: 'BTC', + timeframe: '3M', + nowMs, + currentRate: 160, + }); + expect(result).not.toBeUndefined(); + expect(result!.timeframe).toBe('3M'); + }); +}); + +// ─── alignTimestamps ────────────────────────────────────────────────────────── + +describe('alignTimestamps', () => { + it('returns empty object for empty input', () => { + expect(alignTimestamps({})).toEqual({}); + }); + + it('returns empty arrays for coins with no points', () => { + const result = alignTimestamps({BTC: [], ETH: []}); + expect(result.BTC).toEqual([]); + expect(result.ETH).toEqual([]); + }); + + it('aligns single coin — output equals input', () => { + const pts = makeRatePoints(5, 0, 1000); + const result = alignTimestamps({BTC: pts}); + expect(result.BTC).toHaveLength(5); + // All non-null + result.BTC.forEach(p => expect(p).not.toBeNull()); + }); + + it('aligns two coins of equal length with identical timestamps', () => { + const pts1 = makeRatePoints(5, 1000, 1000); + const pts2 = makeRatePoints(5, 1000, 1000, 10); + const result = alignTimestamps({BTC: pts1, ETH: pts2}); + expect(result.BTC).toHaveLength(result.ETH!.length); + }); + + it('pads shorter series with nulls when timestamps differ slightly', () => { + // BTC has 5 points, ETH has 4 — offset by 1 step + const pts1 = makeRatePoints(5, 1000, 1000); + const pts2 = makeRatePoints(4, 2000, 1000); // starts 1 step later + const result = alignTimestamps({BTC: pts1, ETH: pts2}); + // Both outputs should have the same length + expect(result.BTC!.length).toBe(result.ETH!.length); + }); + + it('returns null-filled arrays when coins have 0 points but others have points', () => { + const pts = makeRatePoints(3, 0, 1000); + const result = alignTimestamps({BTC: pts, ETH: []}); + // ETH should have some nulls since it contributes no data + expect(result.ETH!.every(p => p === null)).toBe(true); + expect(result.BTC!.length).toBe(result.ETH!.length); + }); +}); + +// ─── downsampleTimestamps ───────────────────────────────────────────────────── + +describe('downsampleTimestamps', () => { + it('returns empty object for empty input', () => { + expect(downsampleTimestamps({}, 5)).toEqual({}); + }); + + it('throws for empty arrays', () => { + expect(() => downsampleTimestamps({BTC: []}, 1)).toThrow( + 'cannot downsample empty arrays', + ); + }); + + it('throws when arrays have different lengths', () => { + const a = [null, null, null] as any[]; + const b = [null, null] as any[]; + expect(() => downsampleTimestamps({BTC: a, ETH: b}, 2)).toThrow( + 'all aligned arrays must have the same length', + ); + }); + + it('downsamples evenly with strategy=even (default)', () => { + const pts = makeRatePoints(10, 0, 1000); + const aligned: AlignedRatesByCoin = {BTC: pts}; + const result = downsampleTimestamps(aligned, 5); + expect(result.BTC).toHaveLength(5); + }); + + it('keeps first and last point with even downsampling', () => { + const pts = makeRatePoints(10, 0, 1000); + const aligned: AlignedRatesByCoin = {BTC: pts}; + const result = downsampleTimestamps(aligned, 5); + const arr = result.BTC!; + expect(arr[0]).toEqual(pts[0]); + expect(arr[arr.length - 1]).toEqual(pts[9]); + }); + + it('downsamples with strategy=lttb', () => { + const pts = makeRatePoints(10, 0, 1000); + const aligned: AlignedRatesByCoin = {BTC: pts}; + const result = downsampleTimestamps(aligned, 5, {strategy: 'lttb'}); + expect(result.BTC).toHaveLength(5); + }); + + it('applies shared indices to all coins by default (mode=shared)', () => { + const pts1 = makeRatePoints(10, 0, 1000); + const pts2 = makeRatePoints(10, 0, 1000, 50); + const aligned: AlignedRatesByCoin = {BTC: pts1, ETH: pts2}; + const result = downsampleTimestamps(aligned, 4); + expect(result.BTC).toHaveLength(4); + expect(result.ETH).toHaveLength(4); + // Shared: indices are the same so timestamps match + result.BTC!.forEach((p, i) => { + const ep = result.ETH![i]; + if (p && ep) { + expect(p.ts).toBe(ep.ts); + } + }); + }); + + it('applies per-coin indices with mode=per_coin', () => { + const pts1 = makeRatePoints(10, 0, 1000); + const pts2 = makeRatePoints(10, 0, 1000, 50); + const aligned: AlignedRatesByCoin = {BTC: pts1, ETH: pts2}; + const result = downsampleTimestamps(aligned, 4, {mode: 'per_coin'}); + expect(result.BTC).toHaveLength(4); + expect(result.ETH).toHaveLength(4); + }); + + it('throws for unsupported strategy', () => { + const pts = makeRatePoints(10, 0, 1000); + const aligned: AlignedRatesByCoin = {BTC: pts}; + expect(() => + downsampleTimestamps(aligned, 4, {strategy: 'bogus' as any}), + ).toThrow('unsupported strategy'); + }); + + it('throws for unsupported mode', () => { + const pts = makeRatePoints(10, 0, 1000); + const aligned: AlignedRatesByCoin = {BTC: pts}; + expect(() => + downsampleTimestamps(aligned, 4, {mode: 'unknown' as any}), + ).toThrow('unsupported mode'); + }); + + it('with targetLen === len returns the same points in all coins', () => { + const pts = makeRatePoints(5, 0, 1000); + const aligned: AlignedRatesByCoin = {BTC: pts}; + const result = downsampleTimestamps(aligned, 5); + expect(result.BTC).toHaveLength(5); + result.BTC!.forEach((p, i) => { + expect(p).toEqual(pts[i]); + }); + }); + + it('respects driverCoin option in shared mode with lttb', () => { + const pts1 = makeRatePoints(10, 0, 1000); // BTC + const pts2 = makeRatePoints(10, 0, 1000, 50); // ETH + // some nulls in BTC to make ETH have more non-null + const withNulls: AlignedRatesByCoin = { + BTC: [null, null, ...pts1.slice(2)], + ETH: pts2, + }; + const result = downsampleTimestamps(withNulls, 4, { + strategy: 'lttb', + mode: 'shared', + driverCoin: 'ETH', + }); + expect(result.BTC).toHaveLength(4); + expect(result.ETH).toHaveLength(4); + }); +}); + +// ─── downsampleSeries ───────────────────────────────────────────────────────── + +describe('downsampleSeries', () => { + it('returns empty array for empty input', () => { + expect(downsampleSeries([], 5)).toEqual([]); + }); + + it('passes through series when length <= targetLen', () => { + const pts = makeRatePoints(3, 0, 1000); + const result = downsampleSeries(pts, 10); + // Filters nulls and returns all points + expect(result).toHaveLength(3); + }); + + it('downsamples when length > targetLen', () => { + const pts = makeRatePoints(20, 0, 1000); + const result = downsampleSeries(pts, 5); + expect(result).toHaveLength(5); + }); + + it('filters out null points from result', () => { + const pts: Array = [ + null, + {ts: 1000, rate: 10}, + null, + {ts: 3000, rate: 30}, + ]; + const result = downsampleSeries(pts, 4); + // All nulls filtered + expect(result.every(p => p !== null)).toBe(true); + }); + + it('filters nulls after downsampling', () => { + const pts: Array = [ + null, + ...makeRatePoints(10, 1000, 1000), + ]; + const result = downsampleSeries(pts, 5); + expect(result.every(p => p !== null && p.ts !== undefined)).toBe(true); + }); +}); + +// ─── trimTimestamps ─────────────────────────────────────────────────────────── + +describe('trimTimestamps', () => { + it('returns empty object for empty input', () => { + expect(trimTimestamps({})).toEqual({}); + }); + + it('returns empty arrays for coins with 0 points', () => { + const result = trimTimestamps({BTC: [], ETH: []}); + expect(result.BTC).toEqual([]); + expect(result.ETH).toEqual([]); + }); + + it('throws when arrays have different lengths', () => { + const a = [null, null, null] as any[]; + const b = [null, null] as any[]; + expect(() => trimTimestamps({BTC: a, ETH: b})).toThrow( + 'all aligned arrays must have the same length', + ); + }); + + it('does not trim when all coins have data at every position', () => { + const pts1 = makeRatePoints(5, 0, 1000); + const pts2 = makeRatePoints(5, 0, 1000, 50); + const input: AlignedRatesByCoin = {BTC: pts1, ETH: pts2}; + const result = trimTimestamps(input); + expect(result.BTC).toHaveLength(5); + expect(result.ETH).toHaveLength(5); + }); + + it('trims leading nulls when one coin starts later (≤2 missing)', () => { + // ETH starts 1 position later so first slot is null + const btc = makeRatePoints(5, 0, 1000); + const eth: Array = [ + null, + ...makeRatePoints(4, 1000, 1000), + ]; + const input: AlignedRatesByCoin = {BTC: btc, ETH: eth}; + const result = trimTimestamps(input); + // Leading null gets trimmed (only 1 coin missing, ≤2) + expect(result.BTC!.length).toBeLessThan(5); + expect(result.BTC!.length).toBe(result.ETH!.length); + }); + + it('does NOT trim leading nulls when a single coin has >2 nulls in the trim window', () => { + // BTC has 3 leading nulls, ETH has 3 leading nulls → allHaveRateAt fails for + // positions 0,1,2 so trimStart=3. countMissingOnStart(BTC, 3)=3 > 2 → no trim. + const btc: Array = [ + null, + null, + null, + ...makeRatePoints(2, 3000, 1000), + ]; + const eth: Array = [ + null, + null, + null, + ...makeRatePoints(2, 3000, 1000, 20), + ]; + const input: AlignedRatesByCoin = {BTC: btc, ETH: eth}; + const result = trimTimestamps(input); + // maxMissing = 3 > 2 → trimStart reset to 0 → full length preserved + expect(result.BTC).toHaveLength(5); + expect(result.ETH).toHaveLength(5); + }); + + it('trims trailing nulls when one coin ends earlier (≤2 missing)', () => { + const btc = makeRatePoints(5, 0, 1000); + const eth: Array = [...makeRatePoints(4, 0, 1000), null]; + const input: AlignedRatesByCoin = {BTC: btc, ETH: eth}; + const result = trimTimestamps(input); + expect(result.BTC!.length).toBeLessThan(5); + expect(result.BTC!.length).toBe(result.ETH!.length); + }); + + it('does NOT trim trailing nulls when a single coin has >2 nulls at the end', () => { + // Both BTC and ETH have 3 trailing nulls → trimEnd=3, countMissingOnEnd(BTC,3)=3 > 2 → no trim + const btc: Array = [ + ...makeRatePoints(2, 0, 1000), + null, + null, + null, + ]; + const eth: Array = [ + ...makeRatePoints(2, 0, 1000, 20), + null, + null, + null, + ]; + const input: AlignedRatesByCoin = {BTC: btc, ETH: eth}; + const result = trimTimestamps(input); + // maxMissing = 3 > 2 → trimEnd reset to 0 → full length preserved + expect(result.BTC).toHaveLength(5); + expect(result.ETH).toHaveLength(5); + }); + + it('preserves all data when all points are non-null', () => { + const pts = makeRatePoints(4, 0, 1000); + const result = trimTimestamps({ONLY: pts}); + expect(result.ONLY).toHaveLength(4); + result.ONLY!.forEach(p => expect(p).not.toBeNull()); + }); +}); diff --git a/src/utils/text.spec.ts b/src/utils/text.spec.ts new file mode 100644 index 0000000000..d5d60ec58f --- /dev/null +++ b/src/utils/text.spec.ts @@ -0,0 +1,30 @@ +import {arrayToSentence} from './text'; + +describe('arrayToSentence', () => { + it('returns empty string for empty array', () => { + expect(arrayToSentence([])).toBe(''); + }); + + it('returns empty string for null/undefined', () => { + expect(arrayToSentence(null as any)).toBe(''); + expect(arrayToSentence(undefined as any)).toBe(''); + }); + + it('returns the single item for a one-element array', () => { + expect(arrayToSentence(['apples'])).toBe('apples'); + }); + + it('joins two items with "and"', () => { + expect(arrayToSentence(['apples', 'bananas'])).toBe('apples and bananas'); + }); + + it('joins three items with Oxford comma', () => { + expect(arrayToSentence(['apples', 'bananas', 'oranges'])).toBe( + 'apples, bananas, and oranges', + ); + }); + + it('joins four items with Oxford comma', () => { + expect(arrayToSentence(['a', 'b', 'c', 'd'])).toBe('a, b, c, and d'); + }); +}); diff --git a/test/mocks/SafeAreaView.js b/test/mocks/SafeAreaView.js new file mode 100644 index 0000000000..2f80021204 --- /dev/null +++ b/test/mocks/SafeAreaView.js @@ -0,0 +1,9 @@ +const React = require('react'); +const {View} = require('react-native'); + +const SafeAreaView = ({children, style, testID}) => + React.createElement(View, {style, testID}, children); + +SafeAreaView.displayName = 'SafeAreaView'; + +module.exports = {default: SafeAreaView}; diff --git a/test/setup.js b/test/setup.js index 09be21c278..1bb3023c62 100644 --- a/test/setup.js +++ b/test/setup.js @@ -10,16 +10,22 @@ jest.mock('react-native-haptic-feedback', () => { }; }); -jest.mock('react-native/Libraries/Utilities/Platform', () => ({ - OS: 'android', // or 'ios' - select: () => null, -})); +jest.mock('react-native/Libraries/Utilities/Platform', () => { + const Platform = { + OS: 'android', + Version: 24, + isPad: false, + isTV: false, + isTesting: true, + select: spec => spec['android'] ?? null, + }; + return {...Platform, default: Platform}; +}); jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'), ); global.__reanimatedWorkletInit = jest.fn(); -jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); jest.mock('react-native-permissions', () => require('react-native-permissions/mock'), @@ -85,12 +91,25 @@ jest.mock('@react-navigation/native', () => { navigate: jest.fn(), dispatch: jest.fn(), addListener: jest.fn(), + reset: jest.fn(), + goBack: jest.fn(), })), useNavigation: () => ({ navigate: jest.fn(), dispatch: jest.fn(), addListener: jest.fn(), }), + useTheme: () => ({ + dark: false, + colors: { + primary: '#4f6ef7', + background: '#ffffff', + card: '#ffffff', + text: '#000000', + border: '#e5e5e5', + notification: '#ff3b30', + }, + }), }; }); @@ -109,10 +128,10 @@ jest.mock('@walletconnect/core', () => ({ Core: jest.fn(), })); -jest.mock('@walletconnect/web3wallet', () => ({ +jest.mock('@reown/walletkit', () => ({ __esModule: true, default: () => jest.fn(), - Web3Wallet: jest.fn(() => ({ + WalletKit: jest.fn(() => ({ init: jest.fn(), })), })); @@ -156,3 +175,178 @@ jest.mock('@ledgerhq/react-native-hw-transport-ble', () => ({ open: jest.fn(() => Promise.resolve('mocked transport instance')), }, })); + +// SafeAreaView is deprecated in RN 0.82 and fails to load its native deps in tests. +// This mock intercepts the require inside react-native/index.js's SafeAreaView getter. +jest.mock('react-native/Libraries/Components/SafeAreaView/SafeAreaView', () => { + const React = require('react'); + function SafeAreaView({children, style, testID}) { + return React.createElement('View', {style, testID}, children); + } + SafeAreaView.displayName = 'SafeAreaView'; + return {default: SafeAreaView}; +}); + +// Braze — ESM-only dist; mock to avoid parse errors +jest.mock('@braze/react-native-sdk', () => ({ + __esModule: true, + default: { + logCustomEvent: jest.fn(), + setCustomUserAttribute: jest.fn(), + changeUser: jest.fn(), + requestPushPermission: jest.fn(), + logPurchase: jest.fn(), + setEmail: jest.fn(), + setFirstName: jest.fn(), + }, + Braze: {}, +})); + +// @shopify/react-native-skia — ESM; mock Canvas/Path/Skia primitives +jest.mock('@shopify/react-native-skia', () => ({ + __esModule: true, + Canvas: 'Canvas', + Path: 'Path', + Skia: {Path: jest.fn(() => ({moveTo: jest.fn(), lineTo: jest.fn(), close: jest.fn()}))}, + useValue: jest.fn(() => ({current: 0})), + useComputedValue: jest.fn(() => ({current: 0})), + runTiming: jest.fn(), + useTouchHandler: jest.fn(() => ({})), + useSharedValueEffect: jest.fn(), + Group: 'Group', + Fill: 'Fill', +})); + +// react-native-in-app-review — native module +jest.mock('react-native-in-app-review', () => ({ + __esModule: true, + default: {RequestInAppReview: jest.fn(), isAvailable: jest.fn(() => false)}, +})); + +// react-native-webview — uses TurboModuleRegistry; mock to avoid native errors +jest.mock('react-native-webview', () => ({ + __esModule: true, + default: 'WebView', + WebView: 'WebView', +})); + +// @preeternal/react-native-cookie-manager — ESM module; mock to avoid parse errors +jest.mock('@preeternal/react-native-cookie-manager', () => ({ + __esModule: true, + default: { + clearAll: jest.fn(() => Promise.resolve()), + clearByName: jest.fn(() => Promise.resolve()), + getAll: jest.fn(() => Promise.resolve({})), + set: jest.fn(() => Promise.resolve()), + }, +})); + +// react-native-bootsplash — uses TurboModuleRegistry; mock to avoid native errors +jest.mock('react-native-bootsplash', () => ({ + __esModule: true, + default: { + hide: jest.fn(() => Promise.resolve()), + show: jest.fn(() => Promise.resolve()), + isVisible: jest.fn(() => Promise.resolve(false)), + getContainerStyle: jest.fn(() => ({})), + useHideAnimation: jest.fn(() => ({})), + }, +})); + +// react-native-safe-area-context +jest.mock('react-native-safe-area-context', () => ({ + SafeAreaView: ({children}) => children, + SafeAreaProvider: ({children}) => children, + useSafeAreaInsets: () => ({top: 0, bottom: 0, left: 0, right: 0}), + SafeAreaInsetsContext: {Consumer: ({children}) => children({top: 0, bottom: 0, left: 0, right: 0})}, +})); + +// Sentry — ESM-only dist; mock to avoid parse errors +jest.mock('@sentry/react-native', () => ({ + init: jest.fn(), + captureException: jest.fn(), + captureMessage: jest.fn(), + addBreadcrumb: jest.fn(), + setUser: jest.fn(), + setTag: jest.fn(), + setTags: jest.fn(), + setContext: jest.fn(), + withScope: jest.fn(cb => cb({setTag: jest.fn(), setExtra: jest.fn()})), + Severity: {Error: 'error', Warning: 'warning', Info: 'info'}, + ReactNavigationInstrumentation: jest.fn(), + ReactNativeTracing: jest.fn(), +})); + + +// BitcoreTSS — pulls in ESM-only crypto libs (paillier-bigint, bigint-mod-arith). +// Wallet address tests don't need TSS, so mock the whole package. +jest.mock('@bitpay-labs/bitcore-tss', () => ({__esModule: true, default: {}})); + +// Solana — ESM-only packages; mock entirely so Jest doesn't try to parse .mjs +jest.mock('@solana/web3.js', () => ({ + PublicKey: jest.fn(key => ({toBase58: () => key, toString: () => key})), + Transaction: jest.fn(), + SystemProgram: {transfer: jest.fn()}, + LAMPORTS_PER_SOL: 1000000000, +})); +jest.mock('@solana/kit', () => ({})); +jest.mock('@solana-program/token-2022', () => ({findAssociatedTokenPda: jest.fn()})); + +// Ethers — large lib, mock for unit tests +jest.mock('ethers', () => ({ + ethers: {utils: {isAddress: jest.fn(() => true), getAddress: jest.fn(a => a)}, BigNumber: {from: jest.fn()}}, + utils: {isAddress: jest.fn(() => true), getAddress: jest.fn(a => a)}, + BigNumber: {from: jest.fn()}, +})); + +// @gorhom/bottom-sheet — requires BottomSheetModalProvider context; minimal mock +jest.mock('@gorhom/bottom-sheet', () => { + const React = require('react'); + const noop = () => {}; + class BottomSheetModal extends React.Component { + present() {} + dismiss() {} + snapToIndex() {} + close() {} + render() { return this.props.children || null; } + } + return { + __esModule: true, + default: ({children}) => children, + BottomSheet: ({children}) => children, + BottomSheetModal, + BottomSheetView: ({children}) => children, + BottomSheetModalProvider: ({children}) => children, + BottomSheetBackdrop: noop, + BottomSheetScrollView: ({children}) => children, + BottomSheetFlatList: ({children}) => children, + BottomSheetTextInput: 'TextInput', + useBottomSheet: () => ({close: noop, expand: noop, collapse: noop, snapToIndex: noop}), + useBottomSheetModal: () => ({dismiss: noop, dismissAll: noop, present: noop}), + }; +}); + +// react-native-vision-camera — TurboModule; mock to avoid native errors +jest.mock('react-native-vision-camera', () => ({ + __esModule: true, + Camera: 'Camera', + useCameraDevice: jest.fn(() => ({id: 'mock-device'})), + useCameraPermission: jest.fn(() => ({hasPermission: false, requestPermission: jest.fn()})), + useCameraFormat: jest.fn(() => undefined), + useCodeScanner: jest.fn(() => ({})), + getCameraDevice: jest.fn(() => null), +})); + +// react-native-document-picker — TurboModule; mock to avoid native errors +jest.mock('react-native-document-picker', () => ({ + __esModule: true, + default: { + pick: jest.fn(() => Promise.resolve([])), + pickSingle: jest.fn(() => Promise.resolve(null)), + releaseSecureAccess: jest.fn(() => Promise.resolve()), + isCancel: jest.fn(() => false), + types: {allFiles: 'public.item', pdf: 'com.adobe.pdf', images: 'public.image'}, + }, + isCancel: jest.fn(() => false), + types: {allFiles: 'public.item', pdf: 'com.adobe.pdf', images: 'public.image'}, +})); diff --git a/yarn.lock b/yarn.lock index c1ac37c2b4..64c8d2be14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16218,16 +16218,7 @@ string-range@~1.2, string-range@~1.2.1: resolved "https://registry.yarnpkg.com/string-range/-/string-range-1.2.2.tgz#a893ed347e72299bc83befbbf2a692a8d239d5dd" integrity sha512-tYft6IFi8SjplJpxCUxyqisD3b+R2CSkomrtJYCkvuf1KuCAWgz7YXt4O0jip7efpfCemwHEzTEAO8EuOYgh3w== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -16332,7 +16323,7 @@ stringify-entities@^3.1.0: character-entities-legacy "^1.0.0" xtend "^4.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -16346,13 +16337,6 @@ strip-ansi@^5.0.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -17740,7 +17724,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -17758,15 +17742,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 3db75183b05610a6635ec4e56b0c3c21b92f5ff7 Mon Sep 17 00:00:00 2001 From: Gustavo Cortez Date: Thu, 23 Apr 2026 10:28:49 -0300 Subject: [PATCH 008/138] Braze: Fix - Prevent remove oldEid before merge data to newEid --- src/Root.tsx | 25 ----------- src/lib/Braze/index.ts | 4 ++ src/store/bitpay-id/bitpay-id.effects.ts | 56 ++++++++++-------------- 3 files changed, 27 insertions(+), 58 deletions(-) diff --git a/src/Root.tsx b/src/Root.tsx index f7105d1e95..60b447f8dd 100644 --- a/src/Root.tsx +++ b/src/Root.tsx @@ -145,7 +145,6 @@ import { successAddWallet, successGetReceiveAddress, } from './store/wallet/wallet.actions'; -import {BrazeWrapper} from './lib/Braze'; import {selectSettingsNotificationState} from './store/app/app.selectors'; import {HeaderShownContext} from '@react-navigation/elements'; import PaymentSent from './navigation/wallet/components/PaymentSent'; @@ -655,30 +654,6 @@ export default () => { return () => subscriptionAppStateChange.remove(); }, [pinLockActive, biometricLockActive, onboardingCompleted]); - useEffect(() => { - const eventBrazeListener = DeviceEventEmitter.addListener( - DeviceEmitterEvents.SHOULD_DELETE_BRAZE_USER, - async ({oldEid, newEid}) => { - await sleep(20000); - logManager.info('Deleting old user EID: ', oldEid); - try { - await BrazeWrapper.delete(oldEid); - } catch (error) { - const errMsg = - error instanceof Error ? error.message : JSON.stringify(error); - logManager.error(`Deleting old user EID failed: ${errMsg}`); - } - // Wait for a few seconds to ensure the user is deleted - await sleep(5000); - Analytics.endMergingUser(); - }, - ); - - return () => { - eventBrazeListener.remove(); - }; - }, []); - // Patch BWC logger to forward logs to the debug screen. // Note: BWC logs full request bodies — we filter long messages to avoid clutter. useEffect(() => { diff --git a/src/lib/Braze/index.ts b/src/lib/Braze/index.ts index b7ba678194..4854a74948 100644 --- a/src/lib/Braze/index.ts +++ b/src/lib/Braze/index.ts @@ -221,6 +221,10 @@ class BrazeClientWrapper { } if (userId) { + // Flush buffered events for the current user before switching, so they + // land on Braze servers under the old EID and can be captured by a + // subsequent server-side merge call. + Braze.requestImmediateDataFlush(); Braze.changeUser(userId); const {status} = await checkNotifications().catch(() => ({ status: null, diff --git a/src/store/bitpay-id/bitpay-id.effects.ts b/src/store/bitpay-id/bitpay-id.effects.ts index af15020108..829753a28d 100644 --- a/src/store/bitpay-id/bitpay-id.effects.ts +++ b/src/store/bitpay-id/bitpay-id.effects.ts @@ -25,8 +25,6 @@ import {getCoinAndChainFromCurrencyCode} from '../../navigation/bitpay-id/utils/ import axios from 'axios'; import {BASE_BITPAY_URLS, NO_CACHE_HEADERS} from '../../constants/config'; import {setBrazeEid, setEmailNotificationsAccepted} from '../app/app.actions'; -import {DeviceEmitterEvents} from '../../constants/device-emitter-events'; -import {DeviceEventEmitter} from 'react-native'; import { getPasskeyCredentials, getPasskeyStatus, @@ -39,6 +37,7 @@ import { import {logManager} from '../../managers/LogManager'; import {ongoingProcessManager} from '../../managers/OngoingProcessManager'; import {clearAllCookiesEverywhere} from '../../utils/cookieAuth'; +import {sleep} from '../../utils/helper-methods'; interface StartLoginParams { email?: string; @@ -74,47 +73,38 @@ export const startBitPayIdAnalyticsInit = } } - // Check if Braze EID exists and not the same - // Merge ONLY anonymous EIDs - // If login with any other BitPayID, we shouldn't delete/merge previous user - // Only switch to a new EID with setBrazeEid + const previousBrazeEid = APP.brazeEid; + dispatch(setBrazeEid(eid)); + await dispatch( + Analytics.identify(eid, { + email, + firstName: givenName, + lastName: familyName, + }), + ); + if ( - APP.brazeEid && - APP.brazeEid !== eid && - isAnonymousBrazeEid(APP.brazeEid) + previousBrazeEid && + previousBrazeEid !== eid && + isAnonymousBrazeEid(previousBrazeEid) ) { Analytics.startMergingUser(); - // Should migrate the user to the new EID - logManager.info( - '[startBitPayIdAnalyticsInit] Merging current user to new EID: ', - eid, - ); try { - await BrazeWrapper.merge(APP.brazeEid, eid); - // Emit event to delete old user - DeviceEventEmitter.emit( - DeviceEmitterEvents.SHOULD_DELETE_BRAZE_USER, - { - oldEid: APP.brazeEid, - newEid: eid, - }, + logManager.info( + '[Braze] Merge oldEid/newEid: ', + previousBrazeEid, + eid, ); + await BrazeWrapper.merge(previousBrazeEid, eid); } catch (error) { const errMsg = error instanceof Error ? error.message : JSON.stringify(error); - logManager.error( - `[startBitPayIdAnalyticsInit] Merging current user failed: ${errMsg}`, - ); + logManager.error(`[Braze] Merge EID failed: ${errMsg}`); } + await sleep(5000); + Analytics.endMergingUser(); } - dispatch(setBrazeEid(eid)); - await dispatch( - Analytics.identify(eid, { - email, - firstName: givenName, - lastName: familyName, - }), - ); + // Set email notifications and push notifications after Braze EID is set dispatch( setEmailNotifications( From cd6272d7588ae416a0abbdfcb4e7cff45525bfd4 Mon Sep 17 00:00:00 2001 From: Gustavo Cortez Date: Thu, 23 Apr 2026 18:18:38 -0300 Subject: [PATCH 009/138] Braze: Fix - Ensure new users start with email notifications subscribed --- src/store/app/app.effects.ts | 11 +--- src/store/bitpay-id/bitpay-id.effects.spec.ts | 8 ++- src/store/bitpay-id/bitpay-id.effects.ts | 54 +++++-------------- 3 files changed, 21 insertions(+), 52 deletions(-) diff --git a/src/store/app/app.effects.ts b/src/store/app/app.effects.ts index 36477c180f..179f3ed7e6 100644 --- a/src/store/app/app.effects.ts +++ b/src/store/app/app.effects.ts @@ -945,11 +945,7 @@ export const setAnnouncementsNotifications = }; export const setEmailNotifications = - ( - accepted: boolean, - email: string | null, - agreedToMarketingCommunications?: boolean, - ): Effect => + (accepted: boolean, email: string | null): Effect => (dispatch, getState) => { const { WALLET: {keys}, @@ -966,10 +962,7 @@ export const setEmailNotifications = Braze.setEmail(email); } Braze.setEmailNotificationSubscriptionType( - (bitpayUser && - bitpayUser?.verified && - bitpayUser?.optInEmailMarketing) || - agreedToMarketingCommunications + bitpayUser && bitpayUser?.verified && bitpayUser?.optInEmailMarketing ? Braze.NotificationSubscriptionTypes.OPTED_IN : Braze.NotificationSubscriptionTypes.SUBSCRIBED, ); diff --git a/src/store/bitpay-id/bitpay-id.effects.spec.ts b/src/store/bitpay-id/bitpay-id.effects.spec.ts index 381334d0ce..340feb01e4 100644 --- a/src/store/bitpay-id/bitpay-id.effects.spec.ts +++ b/src/store/bitpay-id/bitpay-id.effects.spec.ts @@ -74,6 +74,7 @@ jest.mock('../analytics/analytics.effects', () => ({ track: jest.fn(() => ({type: 'ANALYTICS/TRACK'})), identify: jest.fn(() => () => Promise.resolve()), startMergingUser: jest.fn(), + endMergingUser: jest.fn(), }, })); @@ -150,6 +151,7 @@ jest.mock('../../api/bitpay', () => ({ import AuthApi from '../../api/auth'; import UserApi from '../../api/user'; import {getPasskeyStatus, signInWithPasskey} from '../../utils/passkey'; +import * as helperMethods from '../../utils/helper-methods'; import {isAnonymousBrazeEid} from '../app/app.effects'; import {BrazeWrapper} from '../../lib/Braze'; @@ -159,6 +161,9 @@ const MockGetPasskeyStatus = getPasskeyStatus as jest.Mock; const MockSignInWithPasskey = signInWithPasskey as jest.Mock; const MockIsAnonymousBrazeEid = isAnonymousBrazeEid as jest.Mock; const MockBrazeWrapperMerge = BrazeWrapper.merge as jest.Mock; +const MockSleep = jest + .spyOn(helperMethods, 'sleep') + .mockResolvedValue(undefined); // --------------------------------------------------------------------------- // Helpers @@ -265,7 +270,7 @@ describe('startBitPayIdStoreInit', () => { const store = baseStore(); const initialData = makeInitialData(); - await store.dispatch(startBitPayIdStoreInit(initialData, true)); + await store.dispatch(startBitPayIdStoreInit(initialData)); // Even with marketing flag set, user should still be initialized const state = store.getState().BITPAY_ID; @@ -309,6 +314,7 @@ describe('startBitPayIdAnalyticsInit', () => { 'old-anon-eid', 'new-eid-xyz', ); + expect(MockSleep).toHaveBeenCalledWith(5000); }); it('does NOT call BrazeWrapper.merge when brazeEid is not anonymous', async () => { diff --git a/src/store/bitpay-id/bitpay-id.effects.ts b/src/store/bitpay-id/bitpay-id.effects.ts index 829753a28d..f5f2c32ea6 100644 --- a/src/store/bitpay-id/bitpay-id.effects.ts +++ b/src/store/bitpay-id/bitpay-id.effects.ts @@ -46,14 +46,10 @@ interface StartLoginParams { } export const startBitPayIdAnalyticsInit = - ( - user: BasicUserInfo, - agreedToMarketingCommunications?: boolean, - ): Effect => + (user: BasicUserInfo): Effect => async (dispatch, getState) => { const {APP} = getState(); const acceptedEmailNotifications = !!APP.emailNotifications?.accepted; - const notificationsAccepted = APP.notificationsAccepted; if (user) { const {eid, name} = user; @@ -106,31 +102,18 @@ export const startBitPayIdAnalyticsInit = } // Set email notifications and push notifications after Braze EID is set - dispatch( - setEmailNotifications( - acceptedEmailNotifications && - user.optInEmailMarketing && - user.verified, - email, - agreedToMarketingCommunications, - ), - ); + dispatch(setEmailNotifications(acceptedEmailNotifications, email)); } }; export const startBitPayIdStoreInit = - ( - initialData: InitialUserData, - agreedToMarketingCommunications?: boolean, - ): Effect => + (initialData: InitialUserData): Effect => async (dispatch, getState) => { const {APP} = getState(); const {basicInfo: user} = initialData; dispatch(BitPayIdActions.successInitializeStore(APP.network, initialData)); try { - dispatch( - startBitPayIdAnalyticsInit(user, agreedToMarketingCommunications), - ); + dispatch(startBitPayIdAnalyticsInit(user)); } catch (err) { const errMsg = err instanceof Error ? err.message : JSON.stringify(err); logManager.error( @@ -183,13 +166,14 @@ export const startCreateAccount = hashedPassword: hashedPassword, salt: salt, agreedToTOSandPP: params.agreedToTOSandPP, - optInEmailMarketing: params.agreedToMarketingCommunications, - attribute: params.agreedToMarketingCommunications - ? 'App Signup' - : undefined, + optInEmailMarketing: agreedToMarketingCommunications, + attribute: agreedToMarketingCommunications ? 'App Signup' : undefined, gCaptchaResponse: params.gCaptchaResponse, }); + // New users accept email notifications by default + dispatch(setEmailNotificationsAccepted(true, params.email)); + // refresh session const session = await AuthApi.fetchSession(APP.network); @@ -198,14 +182,7 @@ export const startCreateAccount = APP.network, session.csrfToken, ); - await dispatch( - startPairAndLoadUser( - APP.network, - secret, - undefined, - agreedToMarketingCommunications, - ), - ); + await dispatch(startPairAndLoadUser(APP.network, secret, undefined)); dispatch(BitPayIdActions.successCreateAccount()); } catch (err) { @@ -536,12 +513,7 @@ export const startDeeplinkPairing = }; export const startPairAndLoadUser = - ( - network: Network, - secret: string, - code?: string, - agreedToMarketingCommunications?: boolean, - ): Effect> => + (network: Network, secret: string, code?: string): Effect> => async (dispatch, getState) => { try { const token = await AuthApi.pair(secret, code); @@ -575,9 +547,7 @@ export const startPairAndLoadUser = ); } - dispatch( - startBitPayIdStoreInit(data.user, agreedToMarketingCommunications), - ); + dispatch(startBitPayIdStoreInit(data.user)); dispatch(CardEffects.startCardStoreInit(data.user)); dispatch(ShopEffects.startFetchCatalog()); dispatch(ShopEffects.startSyncGiftCards()).then(() => From 5c9e42724a881e369c6b214a524e7ab26ad48227 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 28 Apr 2026 11:14:37 -0300 Subject: [PATCH 010/138] TSS: Enhancement - simplify TSS session ID format to txpId:input --- src/store/wallet/effects/tss-send/tss-send.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/store/wallet/effects/tss-send/tss-send.ts b/src/store/wallet/effects/tss-send/tss-send.ts index 7813cb64ac..0fc29d2c6c 100644 --- a/src/store/wallet/effects/tss-send/tss-send.ts +++ b/src/store/wallet/effects/tss-send/tss-send.ts @@ -169,10 +169,9 @@ export const toBwsSignatureFormat = (sig: any, chain: string): string => { export const generateSessionId = ( txp: TransactionProposal, - derivationPath: string, i: number, ): string => { - return `${txp.id}:${derivationPath.replace(/\//g, '-')}-input${i}`; + return `${txp.id}:input${i}`; }; const signInput = async (params: { @@ -515,7 +514,7 @@ export const startTSSSigning = // sigtype: SIGHASH_TYPE, }); const messageHash = Buffer.from(sighashHex, 'hex'); - const sessionId = generateSessionId(txp, derivationPath!, i + 1); + const sessionId = generateSessionId(txp, i); logManager.debug(`Session ID for input ${i + 1}: ${sessionId}`); const signature = await signInput({ tssKey, From b414e513ee36e427c43b667da13aeadc8a8bab52 Mon Sep 17 00:00:00 2001 From: Marty Alcala Date: Mon, 16 Mar 2026 23:43:03 -0400 Subject: [PATCH 011/138] feat: add balance charts --- src/components/charts/BalanceHistoryChart.tsx | 1462 +++++++++++++++++ src/components/charts/ChartAxisLabel.tsx | 215 +++ src/components/charts/ChartChangeRow.tsx | 39 + src/components/charts/ChartSelectionDot.tsx | 54 + .../charts/InteractiveLineChart.tsx | 563 +++++++ src/components/charts/TimeframeSelector.tsx | 104 ++ .../charts/balanceHistoryChartDataPrep.ts | 152 ++ .../charts/balanceHistoryChartHydration.ts | 95 ++ .../balanceHistoryChartOrchestration.ts | 349 ++++ .../balanceHistoryChartRateCacheRevision.ts | 89 + .../charts/balanceHistoryChartSelection.ts | 97 ++ src/components/charts/chartLayout.ts | 39 + src/components/charts/fiatTimeframes.ts | 66 + src/components/charts/sharedValueGuards.ts | 22 + .../charts/timeframeSelectorWidth.ts | 4 + .../useBalanceHistoryChartComputeQueue.ts | 260 +++ .../useBalanceHistoryChartSelectionState.ts | 176 ++ .../useScheduledAfterInteractionsRegistry.ts | 45 + ...useStableBalanceHistoryChartAxisLabels.tsx | 71 + src/constants/currencies.ts | 5 + .../tabs/contacts/components/ContactIcon.tsx | 4 +- src/navigation/tabs/home/HomeRoot.tsx | 25 +- .../tabs/home/components/AssetRow.tsx | 43 +- .../home/components/CollapseContentButton.tsx | 78 + .../tabs/home/components/HeaderScanButton.tsx | 8 +- .../tabs/home/components/LinkingButtons.tsx | 45 +- .../tabs/home/components/PortfolioBalance.tsx | 626 +++++-- .../hooks/portfolioAssetHistoryRequests.ts | 271 +++ .../tabs/home/hooks/usePortfolioAssetRows.ts | 181 +- .../settings/about/screens/PortfolioDebug.tsx | 124 +- .../settings/about/screens/StorageUsage.tsx | 51 +- .../tabs/settings/components/General.tsx | 2 + .../wallet/components/MultipleOutputsTx.tsx | 4 +- .../wallet/hooks/useExchangeRateChartData.ts | 212 ++- .../wallet/screens/AccountDetails.tsx | 183 ++- .../wallet/screens/ExchangeRate.tsx | 847 +++------- .../wallet/screens/ExchangeRate.utils.ts | 32 + src/navigation/wallet/screens/KeyOverview.tsx | 244 ++- .../screens/TransactionProposalDetails.tsx | 6 +- .../wallet/screens/WalletDetails.tsx | 707 +++++--- src/store/backup/fs-backup.ts | 1 + src/store/index.ts | 7 + src/store/portfolio-charts/index.ts | 27 + .../portfolio-charts.actions.ts | 59 + .../portfolio-charts.models.ts | 68 + .../portfolio-charts.reducer.ts | 335 ++++ .../portfolio-charts.types.ts | 72 + src/store/portfolio/portfolio.effects.ts | 135 +- src/store/portfolio/portfolio.models.ts | 7 +- src/store/rate/rate.models.ts | 49 +- src/store/rate/rate.reducer.ts | 23 +- src/store/shop/shop.actions.ts | 10 +- src/store/shop/shop.reducer.ts | 2 + src/store/shop/shop.types.ts | 6 + .../wallet/effects/currencies/currencies.ts | 32 +- src/store/wallet/effects/rates/rates.ts | 118 +- src/store/wallet/effects/send/send.ts | 6 +- src/store/wallet/effects/status/status.ts | 112 +- .../waitForTargetAmountAndUpdateWallet.ts | 329 ++++ src/utils/abort.ts | 29 + src/utils/errors/formatUnknownError.ts | 66 + src/utils/fiatAmountText.ts | 8 + src/utils/fiatTimeframes.ts | 1 + src/utils/portfolio/allocation.ts | 33 +- src/utils/portfolio/assetTheme.ts | 67 + src/utils/portfolio/assets.ts | 1141 ++++++++++--- src/utils/portfolio/chartCache.ts | 797 +++++++++ src/utils/portfolio/chartGraph.ts | 85 + src/utils/portfolio/core/fiatRateSeries.ts | 158 +- src/utils/portfolio/core/fiatTimeframes.ts | 84 + src/utils/portfolio/core/index.ts | 1 + src/utils/portfolio/core/pnl/analysis.ts | 977 ++++++++--- src/utils/portfolio/core/pnl/rates.ts | 53 +- src/utils/portfolio/core/pnl/snapshots.ts | 13 +- src/utils/portfolio/rate.ts | 49 +- .../scheduleAfterInteractionsAndFrames.ts | 167 ++ 76 files changed, 10486 insertions(+), 2241 deletions(-) create mode 100644 src/components/charts/BalanceHistoryChart.tsx create mode 100644 src/components/charts/ChartAxisLabel.tsx create mode 100644 src/components/charts/ChartChangeRow.tsx create mode 100644 src/components/charts/ChartSelectionDot.tsx create mode 100644 src/components/charts/InteractiveLineChart.tsx create mode 100644 src/components/charts/TimeframeSelector.tsx create mode 100644 src/components/charts/balanceHistoryChartDataPrep.ts create mode 100644 src/components/charts/balanceHistoryChartHydration.ts create mode 100644 src/components/charts/balanceHistoryChartOrchestration.ts create mode 100644 src/components/charts/balanceHistoryChartRateCacheRevision.ts create mode 100644 src/components/charts/balanceHistoryChartSelection.ts create mode 100644 src/components/charts/chartLayout.ts create mode 100644 src/components/charts/fiatTimeframes.ts create mode 100644 src/components/charts/sharedValueGuards.ts create mode 100644 src/components/charts/timeframeSelectorWidth.ts create mode 100644 src/components/charts/useBalanceHistoryChartComputeQueue.ts create mode 100644 src/components/charts/useBalanceHistoryChartSelectionState.ts create mode 100644 src/components/charts/useScheduledAfterInteractionsRegistry.ts create mode 100644 src/components/charts/useStableBalanceHistoryChartAxisLabels.tsx create mode 100644 src/navigation/tabs/home/components/CollapseContentButton.tsx create mode 100644 src/navigation/tabs/home/hooks/portfolioAssetHistoryRequests.ts create mode 100644 src/navigation/wallet/screens/ExchangeRate.utils.ts create mode 100644 src/store/portfolio-charts/index.ts create mode 100644 src/store/portfolio-charts/portfolio-charts.actions.ts create mode 100644 src/store/portfolio-charts/portfolio-charts.models.ts create mode 100644 src/store/portfolio-charts/portfolio-charts.reducer.ts create mode 100644 src/store/portfolio-charts/portfolio-charts.types.ts create mode 100644 src/store/wallet/effects/status/waitForTargetAmountAndUpdateWallet.ts create mode 100644 src/utils/abort.ts create mode 100644 src/utils/errors/formatUnknownError.ts create mode 100644 src/utils/fiatAmountText.ts create mode 100644 src/utils/fiatTimeframes.ts create mode 100644 src/utils/portfolio/assetTheme.ts create mode 100644 src/utils/portfolio/chartCache.ts create mode 100644 src/utils/portfolio/chartGraph.ts create mode 100644 src/utils/portfolio/core/fiatTimeframes.ts create mode 100644 src/utils/scheduleAfterInteractionsAndFrames.ts diff --git a/src/components/charts/BalanceHistoryChart.tsx b/src/components/charts/BalanceHistoryChart.tsx new file mode 100644 index 0000000000..5355a84714 --- /dev/null +++ b/src/components/charts/BalanceHistoryChart.tsx @@ -0,0 +1,1462 @@ +import React, { + startTransition, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; +import {StyleProp, View, ViewStyle} from 'react-native'; +import {useTranslation} from 'react-i18next'; +import {useTheme} from 'styled-components/native'; +import type {GraphPoint} from 'react-native-graph'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; +import type { + FiatRateSeriesCache, + FiatRateInterval, + Rates, +} from '../../store/rate/rate.models'; +import { + FIAT_RATE_SERIES_CACHED_INTERVALS, + FIAT_RATE_SERIES_TARGET_POINTS, + hasValidSeriesForCoin, +} from '../../store/rate/rate.models'; +import type { + BalanceSnapshot, + BalanceSnapshotsByWalletId, +} from '../../store/portfolio/portfolio.models'; +import type {Wallet} from '../../store/wallet/wallet.models'; +import { + buildPnlAnalysisSeriesAsync, + type PnlAnalysisPoint, +} from '../../utils/portfolio/core/pnl/analysis'; +import { + getFiatChartTimeframeOptions, + getRangeLabelForFiatTimeframe, + getSeriesIntervalForFiatTimeframe, +} from './fiatTimeframes'; +import TimeframeSelector from './TimeframeSelector'; +import InteractiveLineChart from './InteractiveLineChart'; +import ChartSelectionDot from './ChartSelectionDot'; +import ChartChangeRow from './ChartChangeRow'; +import {Action, LinkBlue, White} from '../../styles/colors'; +import { + buildPnlCurrentRatesByRateKeyFromPortfolioSnapshots, + buildPnlWalletInputsFromPortfolioSnapshotsAsync, + getPortfolioWalletId, + getPortfolioWalletSnapshots, + isPortfolioWalletOnMainnet, + type PnlWalletInputs, +} from '../../utils/portfolio/assets'; +import {useAppDispatch, useAppSelector} from '../../utils/hooks'; +import {fetchFiatRateSeriesInterval} from '../../store/wallet/effects'; +import {isNumberSharedValue, type NumberSharedValue} from './sharedValueGuards'; +import {logManager} from '../../managers/LogManager'; +import { + patchBalanceChartScopeLatestPoints, + touchBalanceChartScope, +} from '../../store/portfolio-charts'; +import { + buildBalanceChartScopeId, + buildBalanceChartTimeframeRevision, + buildHistoricalRateDependencyMetadataFromCache, + buildLatestPointPatchMetadataFromAnalysis, + buildSnapshotVersionSig, + deserializeCachedTimeframeToComputedSeries, + getCachedBalanceChartTimeframe, + getCachedTimeframeStatus, + resolveBalanceChartSeriesExtrema, + getSortedUniqueWalletIds, + serializeComputedSeriesToCachedTimeframe, + stableRateMapRevision, + type HydratedBalanceChartSeries, +} from '../../utils/portfolio/chartCache'; +import {isAbortError} from '../../utils/abort'; +import {normalizeGraphPointsForChart} from '../../utils/portfolio/chartGraph'; +import { + balanceHistoryChartOrchestrationReducer, + createInitialBalanceHistoryChartOrchestrationState, + getTimeframeComputeDisposition, + selectComputedSeriesForAttempt, + selectTimeframeErrorForAttempt, +} from './balanceHistoryChartOrchestration'; +import { + scheduleAfterInteractionsAndFrames, + type ScheduledAfterInteractionsHandle, +} from '../../utils/scheduleAfterInteractionsAndFrames'; +import { + buildBalanceHistoryChartPrepFiatRateSeriesCacheKeys, + buildBalanceHistoryChartRateFetchAssets, + buildBalanceHistoryChartRelevantRateCacheAssets, + getLatestFiatRateSeriesPointTs, +} from './balanceHistoryChartDataPrep'; +import { + buildHydratedBalanceChartTimeframes, + getEffectiveCachedBalanceChartTimeframe, +} from './balanceHistoryChartHydration'; +import { + computeFiatRateSeriesCacheRevision, + getRelevantFiatRateSeriesCacheKeys, +} from './balanceHistoryChartRateCacheRevision'; +import {type ChangeRowData} from './balanceHistoryChartSelection'; +import {formatUnknownError} from '../../utils/errors/formatUnknownError'; +import {useBalanceHistoryChartComputeQueue} from './useBalanceHistoryChartComputeQueue'; +import {useBalanceHistoryChartSelectionState} from './useBalanceHistoryChartSelectionState'; +import {useScheduledAfterInteractionsRegistry} from './useScheduledAfterInteractionsRegistry'; +import {useStableBalanceHistoryChartAxisLabels} from './useStableBalanceHistoryChartAxisLabels'; + +const CHART_LOADER_DELAY_MS = 150; +const CHART_COMPUTE_YIELD_EVERY_POINTS = 4; +const PRECOMPUTE_TIMEFRAME_ORDER: FiatRateInterval[] = [ + '1D', + '1W', + '1M', + 'ALL', + '3M', + '1Y', + '5Y', +]; +const PREP_FX_CACHE_INTERVALS = FIAT_RATE_SERIES_CACHED_INTERVALS; +const EMPTY_BALANCE_SNAPSHOTS: BalanceSnapshot[] = []; + +type AnalysisInputs = PnlWalletInputs; +type ComputedSeries = HydratedBalanceChartSeries; + +const EMPTY_ANALYSIS_INPUTS = (quoteCurrency: string): AnalysisInputs => ({ + wallets: [], + currentRatesByRateKey: {}, + quoteCurrency: (quoteCurrency || '').toUpperCase(), +}); + +const logBalanceHistoryChartError = (context: string, error: unknown) => { + logManager.error( + `[BalanceHistoryChart] ${context}`, + formatUnknownError(error), + ); +}; + +export type BalanceHistoryChartProps = { + wallets: Wallet[]; + snapshotsByWalletId: BalanceSnapshotsByWalletId; + quoteCurrency: string; + initialSelectedTimeframe?: FiatRateInterval; + rates?: Rates; + fiatRateSeriesCache?: FiatRateSeriesCache; + lineColor?: string; + lineThickness?: number; + /** + * Optional scale applied by an ancestor transform. When provided, chart + * strokes (and guide line dash pattern) will be compensated so they remain + * visually constant under scaling. + */ + strokeScale?: number | NumberSharedValue; + /** + * Optional lower bound for `strokeScale`. When provided, the chart can + * reserve enough static path padding up-front to avoid edge clipping at the + * smallest collapse scale. + */ + minStrokeScale?: number; + gradientStartColor?: string; + showLoaderWhenNoSnapshots?: boolean; + /** + * Optional constant offset to add to rendered balance points. + * Useful when a portion of the displayed balance cannot be historized. + */ + balanceOffset?: number; + onSelectedBalanceChange?: (balance?: number) => void; + /** + * Optional content rendered between the PnL change row and the line chart. + */ + preChartContent?: React.ReactNode; + /** + * Optional top spacing for preChartContent. Defaults to 22. + */ + preChartContentTopMargin?: number; + /** + * Optional style override for the PnL change row container. + */ + changeRowStyle?: StyleProp; + /** + * Whether to render the top PnL change row. Defaults to true. + */ + showChangeRow?: boolean; + /** + * Whether to render the timeframe selector row. Defaults to true. + */ + showTimeframeSelector?: boolean; + /** + * Optional opacity for the timeframe selector row. + */ + timeframeSelectorOpacity?: number | NumberSharedValue; + timeframeSelectorHorizontalInset?: string; + timeframeSelectorWidth?: number; + /** + * Disable chart scrubbing interactions. + */ + disablePanGesture?: boolean; + /** + * Optional callback with computed change-row values. + */ + onChangeRowData?: (data: { + percent: number; + deltaFiatFormatted?: string; + rangeLabel?: string; + }) => void; + /** + * Optional opacity for min/max axis labels. + */ + axisLabelOpacity?: number | NumberSharedValue; + onSelectedTimeframeChange?: (timeframe: FiatRateInterval) => void; +}; + +const BalanceHistoryChart = ({ + wallets, + snapshotsByWalletId, + quoteCurrency, + initialSelectedTimeframe = 'ALL', + rates, + fiatRateSeriesCache, + lineColor, + lineThickness, + strokeScale, + minStrokeScale, + gradientStartColor, + showLoaderWhenNoSnapshots = false, + balanceOffset = 0, + onSelectedBalanceChange, + preChartContent, + preChartContentTopMargin = 22, + changeRowStyle, + showChangeRow = true, + showTimeframeSelector = true, + timeframeSelectorOpacity = 1, + timeframeSelectorHorizontalInset, + timeframeSelectorWidth, + disablePanGesture = false, + onChangeRowData, + axisLabelOpacity = 1, + onSelectedTimeframeChange, +}: BalanceHistoryChartProps): React.ReactElement | null => { + const {t} = useTranslation(); + const theme = useTheme(); + const dispatch = useAppDispatch(); + + const [selectedTimeframe, setSelectedTimeframe] = useState( + initialSelectedTimeframe, + ); + const [timeframeState, dispatchTimeframeState] = useReducer( + balanceHistoryChartOrchestrationReducer, + undefined, + createInitialBalanceHistoryChartOrchestrationState, + ); + const [analysisInputs, setAnalysisInputs] = useState(() => ({ + ...EMPTY_ANALYSIS_INPUTS(quoteCurrency), + })); + const [analysisInputsReadyRevision, setAnalysisInputsReadyRevision] = + useState(undefined); + const [analysisInputsErrorRevision, setAnalysisInputsErrorRevision] = + useState(undefined); + const [displayState, setDisplayState] = useState< + | { + series: ComputedSeries; + timeframe: FiatRateInterval; + } + | undefined + >(undefined); + const [hasCompletedInitialAllLoad, setHasCompletedInitialAllLoad] = + useState(false); + const [isChartLoaderVisible, setIsChartLoaderVisible] = useState(false); + + const computeGenerationRef = useRef(0); + const timeframeStateByTimeframe = timeframeState.byTimeframe; + const {cancelAllScheduledWork, trackScheduledHandle, removeScheduledHandle} = + useScheduledAfterInteractionsRegistry(); + + // NOTE: Some call sites may pass inline callbacks. Avoid re-running effects + // (and thus triggering render loops) when the callback identity changes. + const onSelectedBalanceChangeRef = useRef(onSelectedBalanceChange); + useEffect(() => { + onSelectedBalanceChangeRef.current = onSelectedBalanceChange; + }, [onSelectedBalanceChange]); + + const analysisHistoricalDepKeysRef = useRef>(new Set()); + const lastTouchedScopeIdRef = useRef(undefined); + const prepScopedDepIdentityIdsRef = useRef>( + new WeakMap(), + ); + const prepScopedDepIdentityNextIdRef = useRef(1); + const analysisInputsReadyRevisionRef = useRef(undefined); + const scopedWalletsRef = useRef([]); + const scopedSnapshotsByWalletIdRef = useRef({}); + const scopedSnapshotsVersionRef = useRef(undefined); + const fiatRateSeriesCacheRef = useRef(fiatRateSeriesCache); + + const {hasAnySnapshots, hasAnyChartableSnapshots} = useMemo(() => { + let totalSnapshotCount = 0; + let totalChartableSnapshotCount = 0; + + for (const wallet of wallets || []) { + const walletId = getPortfolioWalletId(wallet); + if (!walletId) { + continue; + } + + const snapshotCount = getPortfolioWalletSnapshots( + snapshotsByWalletId, + walletId, + ).length; + totalSnapshotCount += snapshotCount; + + if (!isPortfolioWalletOnMainnet(wallet)) { + continue; + } + + totalChartableSnapshotCount += snapshotCount; + } + + return { + hasAnySnapshots: totalSnapshotCount > 0, + hasAnyChartableSnapshots: totalChartableSnapshotCount > 0, + }; + }, [snapshotsByWalletId, wallets]); + + const getPrepScopedDepIdentityId = useCallback((value: object): number => { + const existingId = prepScopedDepIdentityIdsRef.current.get(value); + if (typeof existingId === 'number') { + return existingId; + } + + const nextId = prepScopedDepIdentityNextIdRef.current; + prepScopedDepIdentityNextIdRef.current += 1; + prepScopedDepIdentityIdsRef.current.set(value, nextId); + return nextId; + }, []); + + const scopedWallets = useMemo(() => { + const previous = scopedWalletsRef.current; + const next = wallets || []; + let didChange = previous.length !== next.length; + + if (!didChange) { + for (let i = 0; i < next.length; i++) { + if (previous[i] !== next[i]) { + didChange = true; + break; + } + } + } + + if (!didChange) { + return previous; + } + + scopedWalletsRef.current = next; + return next; + }, [wallets]); + + const sortedWalletIds = useMemo(() => { + return getSortedUniqueWalletIds((wallets || []).map(getPortfolioWalletId)); + }, [wallets]); + + const scopeId = useMemo(() => { + return buildBalanceChartScopeId({ + walletIds: sortedWalletIds, + quoteCurrency, + balanceOffset, + }); + }, [balanceOffset, quoteCurrency, sortedWalletIds]); + + const snapshotVersionSig = useAppSelector(state => { + return buildSnapshotVersionSig({ + walletIds: sortedWalletIds, + walletSnapshotVersionById: + state.PORTFOLIO_CHARTS.walletSnapshotVersionById || {}, + }); + }); + + const cachedScope = useAppSelector( + state => state.PORTFOLIO_CHARTS.cacheByScopeId[scopeId], + ); + + const scopedSnapshotsByWalletId = useMemo(() => { + const previous = scopedSnapshotsByWalletIdRef.current; + const next: BalanceSnapshotsByWalletId = {}; + let didChange = scopedSnapshotsVersionRef.current !== snapshotVersionSig; + + for (const walletId of sortedWalletIds) { + const snapshots = Array.isArray(snapshotsByWalletId?.[walletId]) + ? snapshotsByWalletId[walletId] + : EMPTY_BALANCE_SNAPSHOTS; + next[walletId] = snapshots; + if (previous[walletId] !== snapshots) { + didChange = true; + } + } + + if (Object.keys(previous).length !== sortedWalletIds.length) { + didChange = true; + } + + if (!didChange) { + return previous; + } + + scopedSnapshotsByWalletIdRef.current = next; + scopedSnapshotsVersionRef.current = snapshotVersionSig; + return next; + }, [snapshotVersionSig, snapshotsByWalletId, sortedWalletIds]); + + const liveSpotRatesByRateKey = useMemo(() => { + return buildPnlCurrentRatesByRateKeyFromPortfolioSnapshots({ + snapshotsByWalletId: snapshotsByWalletId || {}, + wallets: wallets || [], + quoteCurrency, + rates, + }); + }, [quoteCurrency, rates, snapshotsByWalletId, wallets]); + + const preparedSpotRatesByRateKey = analysisInputs.currentRatesByRateKey; + + const liveSpotRatesRevision = useMemo(() => { + return stableRateMapRevision(liveSpotRatesByRateKey); + }, [liveSpotRatesByRateKey]); + + const preparedSpotRatesRevision = useMemo(() => { + return stableRateMapRevision(preparedSpotRatesByRateKey); + }, [preparedSpotRatesByRateKey]); + + // Prefer the prepared rate map once it has caught up with the latest spot + // inputs, but fall back to the live map so cache patching reacts immediately. + const currentSpotRatesByRateKey = useMemo(() => { + return preparedSpotRatesRevision === liveSpotRatesRevision + ? preparedSpotRatesByRateKey + : liveSpotRatesByRateKey; + }, [ + liveSpotRatesByRateKey, + liveSpotRatesRevision, + preparedSpotRatesByRateKey, + preparedSpotRatesRevision, + ]); + + const selectedSeriesInterval = useMemo(() => { + return getSeriesIntervalForFiatTimeframe(selectedTimeframe); + }, [selectedTimeframe]); + + const fiatChartTimeframeOptions = useMemo( + () => getFiatChartTimeframeOptions(t), + [t], + ); + + const analysisInputsBaseKey = useMemo( + () => `${scopeId}|${snapshotVersionSig}`, + [scopeId, snapshotVersionSig], + ); + + const rateFetchAssets = useMemo(() => { + return buildBalanceHistoryChartRateFetchAssets(wallets); + }, [wallets]); + + const relevantRateCacheAssets = useMemo(() => { + return buildBalanceHistoryChartRelevantRateCacheAssets(rateFetchAssets); + }, [rateFetchAssets]); + + const relevantFiatRateSeriesCacheKeys = useMemo(() => { + return getRelevantFiatRateSeriesCacheKeys({ + fiatCode: quoteCurrency, + assets: relevantRateCacheAssets, + timeframes: PRECOMPUTE_TIMEFRAME_ORDER, + }); + }, [quoteCurrency, relevantRateCacheAssets]); + + useEffect(() => { + if ( + !hasAnyChartableSnapshots || + !quoteCurrency || + !rateFetchAssets.length + ) { + return; + } + + for (const asset of rateFetchAssets) { + if ( + hasValidSeriesForCoin({ + cache: fiatRateSeriesCache, + fiatCodeUpper: quoteCurrency, + normalizedCoin: asset.coinForCacheCheck, + intervals: [selectedSeriesInterval], + requireFresh: true, + chain: asset.chain, + tokenAddress: asset.tokenAddress, + }) + ) { + continue; + } + + dispatch( + fetchFiatRateSeriesInterval({ + fiatCode: quoteCurrency, + interval: selectedSeriesInterval, + coinForCacheCheck: asset.coinForCacheCheck, + chain: asset.chain, + tokenAddress: asset.tokenAddress, + }), + ); + } + }, [ + dispatch, + fiatRateSeriesCache, + hasAnyChartableSnapshots, + quoteCurrency, + rateFetchAssets, + selectedSeriesInterval, + ]); + + const cacheRevision = useMemo(() => { + return computeFiatRateSeriesCacheRevision({ + fiatRateSeriesCache, + relevantKeys: relevantFiatRateSeriesCacheKeys, + }); + }, [fiatRateSeriesCache, relevantFiatRateSeriesCacheKeys]); + + const prepFiatRateSeriesCacheKeys = useMemo(() => { + return buildBalanceHistoryChartPrepFiatRateSeriesCacheKeys({ + quoteCurrency, + scopedSnapshotsByWalletId, + prepIntervals: PREP_FX_CACHE_INTERVALS, + }); + }, [quoteCurrency, scopedSnapshotsByWalletId]); + + const prepCacheRevision = useMemo(() => { + return computeFiatRateSeriesCacheRevision({ + fiatRateSeriesCache, + relevantKeys: prepFiatRateSeriesCacheKeys, + }); + }, [fiatRateSeriesCache, prepFiatRateSeriesCacheKeys]); + + const preparedInputsTargetRevision = useMemo(() => { + return [ + analysisInputsBaseKey, + prepCacheRevision, + `wallets:${getPrepScopedDepIdentityId(scopedWallets)}`, + `snapshots:${getPrepScopedDepIdentityId(scopedSnapshotsByWalletId)}`, + ].join('|'); + }, [ + analysisInputsBaseKey, + getPrepScopedDepIdentityId, + prepCacheRevision, + scopedSnapshotsByWalletId, + scopedWallets, + ]); + + useEffect(() => { + fiatRateSeriesCacheRef.current = fiatRateSeriesCache; + }, [fiatRateSeriesCache]); + + const getTimeframeRevision = useCallback( + ( + timeframe: FiatRateInterval, + historicalRateDeps = getCachedBalanceChartTimeframe( + cachedScope?.timeframes, + timeframe, + )?.historicalRateDeps || [], + ) => { + return buildBalanceChartTimeframeRevision({ + scopeId, + timeframe, + snapshotVersionSig, + historicalRateDeps, + currentSpotRatesByRateKey, + }); + }, + [ + cachedScope?.timeframes, + currentSpotRatesByRateKey, + scopeId, + snapshotVersionSig, + ], + ); + + const getTimeframeAttemptRevision = useCallback( + (timeframe: FiatRateInterval) => { + return [ + preparedInputsTargetRevision, + analysisInputsReadyRevision || 'pending', + liveSpotRatesRevision, + cacheRevision, + timeframe, + ].join('|'); + }, + [ + analysisInputsReadyRevision, + cacheRevision, + liveSpotRatesRevision, + preparedInputsTargetRevision, + ], + ); + + const cachedTimeframeStatusByTimeframe = useMemo(() => { + const next: Partial< + Record< + FiatRateInterval, + 'fresh' | 'patchable' | 'stale_historical' | 'missing' + > + > = {}; + + for (const timeframe of PRECOMPUTE_TIMEFRAME_ORDER) { + next[timeframe] = getCachedTimeframeStatus({ + cachedTimeframe: getCachedBalanceChartTimeframe( + cachedScope?.timeframes, + timeframe, + ), + snapshotVersionSig, + currentSpotRatesByRateKey, + fiatRateSeriesCache, + }); + } + + return next; + }, [ + cachedScope?.timeframes, + currentSpotRatesByRateKey, + fiatRateSeriesCache, + snapshotVersionSig, + ]); + + const selectedTimeframeNeedsHistoricalRecompute = useMemo(() => { + const status = + cachedTimeframeStatusByTimeframe[selectedTimeframe] || 'missing'; + return status === 'missing' || status === 'stale_historical'; + }, [cachedTimeframeStatusByTimeframe, selectedTimeframe]); + + const hasAnyBackgroundHistoricalRecomputeNeeded = useMemo(() => { + return PRECOMPUTE_TIMEFRAME_ORDER.some(timeframe => { + if (timeframe === selectedTimeframe) { + return false; + } + + const status = cachedTimeframeStatusByTimeframe[timeframe] || 'missing'; + return status === 'missing' || status === 'stale_historical'; + }); + }, [cachedTimeframeStatusByTimeframe, selectedTimeframe]); + + const shouldPrepareAnalysisInputs = + hasAnyChartableSnapshots && + !!fiatRateSeriesCache && + (selectedTimeframeNeedsHistoricalRecompute || + (hasCompletedInitialAllLoad && + hasAnyBackgroundHistoricalRecomputeNeeded)); + + useEffect(() => { + analysisInputsReadyRevisionRef.current = analysisInputsReadyRevision; + }, [analysisInputsReadyRevision]); + + useEffect(() => { + if (!cachedScope) { + return; + } + if (lastTouchedScopeIdRef.current === scopeId) { + return; + } + + lastTouchedScopeIdRef.current = scopeId; + dispatch( + touchBalanceChartScope({ + scopeId, + }), + ); + }, [cachedScope, dispatch, scopeId]); + + useEffect(() => { + if (!cachedScope) { + return; + } + + const {patchedTimeframes, hydratedTimeframes, selectedHydratedSeries} = + buildHydratedBalanceChartTimeframes({ + timeframes: cachedScope.timeframes, + timeframeOrder: PRECOMPUTE_TIMEFRAME_ORDER, + selectedTimeframe, + cachedStatusByTimeframe: cachedTimeframeStatusByTimeframe, + currentSpotRatesByRateKey, + deserializeTimeframe: deserializeCachedTimeframeToComputedSeries, + getTimeframeRevision, + }); + + startTransition(() => { + dispatchTimeframeState({ + type: 'mergeHydratedSeries', + updates: hydratedTimeframes, + }); + + if (selectedHydratedSeries) { + setDisplayState(prev => + prev?.series === selectedHydratedSeries && + prev?.timeframe === selectedTimeframe + ? prev + : { + series: selectedHydratedSeries, + timeframe: selectedTimeframe, + }, + ); + } + }); + + if (patchedTimeframes.length) { + dispatch( + patchBalanceChartScopeLatestPoints({ + scopeId, + timeframes: patchedTimeframes, + }), + ); + } + }, [ + cachedScope, + cachedTimeframeStatusByTimeframe, + currentSpotRatesByRateKey, + dispatch, + getTimeframeRevision, + scopeId, + selectedTimeframe, + ]); + + const hasAnalysisPreparationError = + analysisInputsErrorRevision === preparedInputsTargetRevision; + + const inputsReady = + shouldPrepareAnalysisInputs && + !!fiatRateSeriesCache && + analysisInputs.wallets.length > 0 && + analysisInputsReadyRevision === preparedInputsTargetRevision && + !hasAnalysisPreparationError; + + const computeSeriesForTimeframe = useCallback( + async ( + timeframe: FiatRateInterval, + signal: AbortSignal, + ): Promise<{ + cacheEntry: ReturnType; + series: ComputedSeries; + }> => { + const nowMs = Date.now(); + + if (!fiatRateSeriesCache) { + throw new Error('fiatRateSeriesCache missing'); + } + if (!analysisInputs.wallets.length) { + throw new Error('analysisInputs.wallets empty'); + } + + const historicalDepKeys = new Set( + Array.from(analysisHistoricalDepKeysRef.current || []), + ); + + const buildAnalysis = (targetNowMs: number) => + buildPnlAnalysisSeriesAsync({ + wallets: analysisInputs.wallets, + timeframe, + quoteCurrency: analysisInputs.quoteCurrency, + fiatRateSeriesCache, + currentRatesByRateKey: + Object.keys(currentSpotRatesByRateKey || {}).length > 0 + ? currentSpotRatesByRateKey + : undefined, + nowMs: targetNowMs, + maxPoints: FIAT_RATE_SERIES_TARGET_POINTS, + signal, + yieldEveryPoints: CHART_COMPUTE_YIELD_EVERY_POINTS, + onHistoricalRateDependency: cacheKey => { + if (cacheKey) { + historicalDepKeys.add(cacheKey); + } + }, + }); + + let result: Awaited>; + try { + result = await buildAnalysis(nowMs); + } catch (firstError) { + if (isAbortError(firstError)) { + throw firstError; + } + + const fallbackNowMs = + getLatestFiatRateSeriesPointTs(fiatRateSeriesCache); + if ( + !fallbackNowMs || + !Number.isFinite(fallbackNowMs) || + fallbackNowMs >= nowMs + ) { + throw firstError; + } + result = await buildAnalysis(fallbackNowMs); + } + + const analysisPoints = result.points || []; + if (analysisPoints.length !== FIAT_RATE_SERIES_TARGET_POINTS) { + throw new Error( + `Unexpected analysis point count: ${analysisPoints.length} (expected ${FIAT_RATE_SERIES_TARGET_POINTS})`, + ); + } + + const rawGraphPoints: GraphPoint[] = analysisPoints.map(point => ({ + date: new Date(point.timestamp), + value: point.totalFiatBalance + balanceOffset, + })); + const graphPoints = normalizeGraphPointsForChart(rawGraphPoints); + const pointByTimestamp = new Map(); + for (let i = 0; i < graphPoints.length; i++) { + pointByTimestamp.set(graphPoints[i].date.getTime(), analysisPoints[i]); + } + + const {minIndex, maxIndex, minPoint, maxPoint} = + resolveBalanceChartSeriesExtrema({ + graphPoints, + balanceOffset, + exactExtrema: result.exactExtrema, + }); + + const patchMetadata = buildLatestPointPatchMetadataFromAnalysis({ + analysisPoints, + wallets: analysisInputs.wallets, + }); + const historicalRateDeps = buildHistoricalRateDependencyMetadataFromCache( + { + depKeys: historicalDepKeys, + fiatRateSeriesCache, + }, + ); + const cacheEntry = serializeComputedSeriesToCachedTimeframe({ + timeframe, + walletIds: sortedWalletIds, + quoteCurrency: analysisInputs.quoteCurrency, + balanceOffset, + snapshotVersionSig, + historicalRateDeps, + analysisPoints, + exactExtrema: result.exactExtrema, + patchMetadata, + }); + + return { + cacheEntry, + series: { + graphPoints, + analysisPoints, + pointByTimestamp, + minIndex, + maxIndex, + minPoint, + maxPoint, + }, + }; + }, + [ + analysisInputs, + balanceOffset, + currentSpotRatesByRateKey, + fiatRateSeriesCache, + snapshotVersionSig, + sortedWalletIds, + ], + ); + + const getComputeDispositionForTimeframe = useCallback( + ( + timeframe: FiatRateInterval, + retryPolicy: 'retry_interrupted_attempts' | 'suppress_after_attempt', + ) => { + const cachedStatus = + cachedTimeframeStatusByTimeframe[timeframe] || 'missing'; + const timeframeRevision = getTimeframeRevision(timeframe); + const attemptRevision = getTimeframeAttemptRevision(timeframe); + + return getTimeframeComputeDisposition({ + cachedStatus, + timeframeRevision, + attemptRevision, + timeframeState: timeframeStateByTimeframe[timeframe], + hasAnySnapshots: hasAnyChartableSnapshots, + inputsReady, + retryPolicy, + }); + }, + [ + cachedTimeframeStatusByTimeframe, + getTimeframeAttemptRevision, + getTimeframeRevision, + hasAnyChartableSnapshots, + inputsReady, + timeframeStateByTimeframe, + ], + ); + + const { + ensureTimeframeComputed, + queueTimeframeCompute, + retainOnlyQueuedTimeframe, + resetComputeQueue, + } = useBalanceHistoryChartComputeQueue({ + balanceOffset, + computeGenerationRef, + computeSeriesForTimeframe, + dispatch, + dispatchTimeframeState, + getComputeDispositionForTimeframe, + getTimeframeAttemptRevision, + getTimeframeRevision, + onComputeError: logBalanceHistoryChartError, + scopeId, + selectedTimeframe, + setDisplayState, + sortedWalletIds, + trackScheduledHandle, + }); + + const invalidateComputeGeneration = useCallback(() => { + computeGenerationRef.current += 1; + cancelAllScheduledWork(); + resetComputeQueue(); + return computeGenerationRef.current; + }, [cancelAllScheduledWork, resetComputeQueue]); + + useEffect(() => { + return () => { + invalidateComputeGeneration(); + }; + }, [invalidateComputeGeneration]); + + const selectedTimeframeRevision = getTimeframeRevision(selectedTimeframe); + const selectedTimeframeAttemptRevision = + getTimeframeAttemptRevision(selectedTimeframe); + const selectedTimeframeState = timeframeStateByTimeframe[selectedTimeframe]; + const selectedComputedSeries = useMemo(() => { + return selectComputedSeriesForAttempt({ + timeframeState: timeframeStateByTimeframe[selectedTimeframe], + timeframeRevision: selectedTimeframeRevision, + attemptRevision: selectedTimeframeAttemptRevision, + }); + }, [ + selectedTimeframe, + selectedTimeframeAttemptRevision, + selectedTimeframeRevision, + timeframeStateByTimeframe, + ]); + const selectedTimeframeError = selectTimeframeErrorForAttempt({ + timeframeState: timeframeStateByTimeframe[selectedTimeframe], + attemptRevision: selectedTimeframeAttemptRevision, + }); + const displayedTimeframe = selectedComputedSeries + ? selectedTimeframe + : displayState?.timeframe ?? selectedTimeframe; + const activeSeries = selectedComputedSeries || displayState?.series; + + const cachedSelectedSeries = useMemo(() => { + const cachedTimeframe = getCachedBalanceChartTimeframe( + cachedScope?.timeframes, + selectedTimeframe, + ); + if (!cachedTimeframe) { + return undefined; + } + + const effectiveCachedTimeframe = getEffectiveCachedBalanceChartTimeframe({ + cachedTimeframe, + status: cachedTimeframeStatusByTimeframe[selectedTimeframe] || 'missing', + currentSpotRatesByRateKey, + }); + + return deserializeCachedTimeframeToComputedSeries(effectiveCachedTimeframe); + }, [ + cachedScope?.timeframes, + cachedTimeframeStatusByTimeframe, + currentSpotRatesByRateKey, + selectedTimeframe, + ]); + + // Swap in the computed series once available. + useEffect(() => { + if (!selectedComputedSeries) { + return; + } + + startTransition(() => { + setDisplayState(prev => + prev?.series === selectedComputedSeries && + prev?.timeframe === selectedTimeframe + ? prev + : { + series: selectedComputedSeries, + timeframe: selectedTimeframe, + }, + ); + }); + }, [selectedComputedSeries, selectedTimeframe]); + + const rangeLabel = useMemo( + () => getRangeLabelForFiatTimeframe(t, displayedTimeframe), + [displayedTimeframe, t], + ); + + const { + displayedChangeRowData, + clearSelection, + onGestureStarted, + onGestureEnded, + onPointSelected, + } = useBalanceHistoryChartSelectionState({ + activeSeries, + cachedSelectedSeries, + selectedTimeframe, + displayedTimeframe, + rangeLabel, + quoteCurrency, + balanceOffset, + timeframeStateByTimeframe, + dispatchTimeframeState, + onSelectedBalanceChangeRef, + onChangeRowData, + }); + + const {MaxAxisLabel, MinAxisLabel} = useStableBalanceHistoryChartAxisLabels({ + activeSeries, + axisLabelOpacity, + quoteCurrency, + }); + + const ensureTimeframeComputedRef = useRef(ensureTimeframeComputed); + useEffect(() => { + ensureTimeframeComputedRef.current = ensureTimeframeComputed; + }, [ensureTimeframeComputed]); + + useEffect(() => { + const generation = invalidateComputeGeneration(); + startTransition(() => { + dispatchTimeframeState({ + type: 'advanceGeneration', + generation, + }); + }); + }, [ + cacheRevision, + liveSpotRatesRevision, + invalidateComputeGeneration, + preparedInputsTargetRevision, + ]); + + // Reset only when the chart scope changes (wallet set / quote / balance offset). + useEffect(() => { + const generation = invalidateComputeGeneration(); + analysisHistoricalDepKeysRef.current = new Set(); + lastTouchedScopeIdRef.current = undefined; + analysisInputsReadyRevisionRef.current = undefined; + setAnalysisInputs(EMPTY_ANALYSIS_INPUTS(quoteCurrency)); + setAnalysisInputsReadyRevision(undefined); + setAnalysisInputsErrorRevision(undefined); + setHasCompletedInitialAllLoad(false); + dispatchTimeframeState({ + type: 'resetAll', + generation, + }); + clearSelection(); + setDisplayState(undefined); + }, [clearSelection, invalidateComputeGeneration, quoteCurrency, scopeId]); + + useEffect(() => { + let prepareHandle: ScheduledAfterInteractionsHandle | undefined; + const shouldResetPreparedInputs = + analysisInputsReadyRevisionRef.current !== preparedInputsTargetRevision; + + if (!hasAnyChartableSnapshots || !shouldPrepareAnalysisInputs) { + setAnalysisInputs(EMPTY_ANALYSIS_INPUTS(quoteCurrency)); + analysisHistoricalDepKeysRef.current = new Set(); + analysisInputsReadyRevisionRef.current = undefined; + setAnalysisInputsReadyRevision(undefined); + setAnalysisInputsErrorRevision(undefined); + return; + } + + // Preparing analysis inputs is scheduled work. Generation invalidations + // cancel all scheduled work, so re-run this effect when the generation + // changes and skip rescheduling when the current target is already ready. + if (inputsReady) { + return; + } + + if (shouldResetPreparedInputs) { + setAnalysisInputs(EMPTY_ANALYSIS_INPUTS(quoteCurrency)); + analysisInputsReadyRevisionRef.current = undefined; + setAnalysisInputsReadyRevision(undefined); + setAnalysisInputsErrorRevision(undefined); + } + + const generation = computeGenerationRef.current; + prepareHandle = scheduleAfterInteractionsAndFrames({ + callback: async signal => { + const historicalDepKeys = new Set(); + const prepared = await buildPnlWalletInputsFromPortfolioSnapshotsAsync( + { + snapshotsByWalletId: scopedSnapshotsByWalletId, + wallets: scopedWallets, + quoteCurrency, + rates, + fiatRateSeriesCache: fiatRateSeriesCacheRef.current, + onHistoricalRateDependency: cacheKey => { + if (cacheKey) { + historicalDepKeys.add(cacheKey); + } + }, + }, + { + signal, + yieldEveryWallets: 1, + yieldEverySnapshots: 150, + }, + ); + + if (computeGenerationRef.current !== generation) { + return; + } + + const didPrepareWallets = prepared.wallets.length > 0; + const nextReadyRevision = didPrepareWallets + ? preparedInputsTargetRevision + : undefined; + analysisHistoricalDepKeysRef.current = historicalDepKeys; + analysisInputsReadyRevisionRef.current = nextReadyRevision; + if (!didPrepareWallets) { + logBalanceHistoryChartError( + 'prepare produced no wallets', + new Error('Prepared analysis inputs were empty.'), + ); + } + startTransition(() => { + if (computeGenerationRef.current !== generation) { + return; + } + + if (!didPrepareWallets) { + setAnalysisInputs(EMPTY_ANALYSIS_INPUTS(prepared.quoteCurrency)); + setAnalysisInputsReadyRevision(undefined); + setAnalysisInputsErrorRevision(preparedInputsTargetRevision); + return; + } + + setAnalysisInputs(prepared); + setAnalysisInputsReadyRevision(nextReadyRevision); + setAnalysisInputsErrorRevision(undefined); + }); + }, + onError: error => { + if ( + computeGenerationRef.current !== generation || + isAbortError(error) + ) { + return; + } + + logBalanceHistoryChartError('prepare failed', error); + analysisHistoricalDepKeysRef.current = new Set(); + analysisInputsReadyRevisionRef.current = undefined; + startTransition(() => { + if (computeGenerationRef.current !== generation) { + return; + } + + setAnalysisInputs(EMPTY_ANALYSIS_INPUTS(quoteCurrency)); + setAnalysisInputsReadyRevision(undefined); + setAnalysisInputsErrorRevision(preparedInputsTargetRevision); + }); + }, + }); + trackScheduledHandle(prepareHandle); + + return () => { + removeScheduledHandle(prepareHandle, true); + }; + }, [ + hasAnyChartableSnapshots, + inputsReady, + preparedInputsTargetRevision, + quoteCurrency, + rates, + scopedSnapshotsByWalletId, + scopedWallets, + shouldPrepareAnalysisInputs, + timeframeState.generation, + trackScheduledHandle, + removeScheduledHandle, + ]); + + // On timeframe change, keep the previously rendered series visible while + // the new timeframe computes (shown with reduced opacity behind loader). + // IMPORTANT: depend ONLY on timeframe/balanceOffset so we don't re-run on + // every render due to callback identity or internal helper identity changes. + useEffect(() => { + clearSelection(); + retainOnlyQueuedTimeframe(selectedTimeframe); + + // Compute selected timeframe (if possible) after painting. + ensureTimeframeComputedRef.current(selectedTimeframe, {prioritize: true}); + }, [ + balanceOffset, + clearSelection, + retainOnlyQueuedTimeframe, + selectedTimeframe, + ]); + + // When inputs become ready, or a revision invalidates an in-flight compute, + // retry the selected timeframe so first-render loaders do not get stuck on + // aborted attempts. + useEffect(() => { + if (!inputsReady) { + return; + } + + // Compute only the selected timeframe to avoid background JS work + // freezing selector interactions. + ensureTimeframeComputed(selectedTimeframe, {prioritize: true}); + }, [ + cacheRevision, + ensureTimeframeComputed, + inputsReady, + liveSpotRatesRevision, + selectedTimeframe, + ]); + + // Opportunistically precompute additional timeframes in background so taps + // switch instantly more often and avoid heavy foreground work. + useEffect(() => { + if (!inputsReady || !hasAnyChartableSnapshots) { + return; + } + if (!hasCompletedInitialAllLoad) { + return; + } + + const nextToPrecompute = PRECOMPUTE_TIMEFRAME_ORDER.find(timeframe => { + if (timeframe === selectedTimeframe) { + return false; + } + + return getComputeDispositionForTimeframe( + timeframe, + 'suppress_after_attempt', + ).shouldQueue; + }); + + if (!nextToPrecompute) { + return; + } + + queueTimeframeCompute(nextToPrecompute, false); + }, [ + getComputeDispositionForTimeframe, + hasAnyChartableSnapshots, + hasCompletedInitialAllLoad, + inputsReady, + queueTimeframeCompute, + selectedTimeframe, + ]); + + const timeframeSelectorOpacityIsSharedValue = isNumberSharedValue( + timeframeSelectorOpacity, + ); + const sharedTimeframeSelectorOpacity = timeframeSelectorOpacityIsSharedValue + ? timeframeSelectorOpacity + : undefined; + const timeframeSelectorOpacityNumber = + typeof timeframeSelectorOpacity === 'number' && + Number.isFinite(timeframeSelectorOpacity) + ? timeframeSelectorOpacity + : 1; + const timeframeSelectorAnimatedStyle = useAnimatedStyle(() => { + const sharedOpacity = sharedTimeframeSelectorOpacity?.value; + return { + opacity: + typeof sharedOpacity === 'number' && Number.isFinite(sharedOpacity) + ? sharedOpacity + : timeframeSelectorOpacityNumber, + }; + }, [sharedTimeframeSelectorOpacity, timeframeSelectorOpacityNumber]); + + // Only show the loader when the selected timeframe does not have a computed + // series yet. If we already have selected-timeframe data, keep it visible at + // full opacity even if background refresh/recompute work is still happening. + const hasRenderableSelectedSeries = + !!selectedComputedSeries || + (displayState?.timeframe === selectedTimeframe && !!displayState?.series); + const isSelectedTimeframePending = + !hasRenderableSelectedSeries && + !selectedTimeframeError && + !hasAnalysisPreparationError; + const isChartLoadingRaw = + hasAnyChartableSnapshots && isSelectedTimeframePending; + + useEffect(() => { + if (!isChartLoadingRaw) { + setIsChartLoaderVisible(false); + if (!hasCompletedInitialAllLoad && selectedTimeframe === 'ALL') { + setHasCompletedInitialAllLoad(true); + } + return; + } + + const isInitialAllLoad = + selectedTimeframe === 'ALL' && !hasCompletedInitialAllLoad; + if (isInitialAllLoad) { + setIsChartLoaderVisible(true); + return; + } + + setIsChartLoaderVisible(false); + const timer = setTimeout(() => { + setIsChartLoaderVisible(true); + }, CHART_LOADER_DELAY_MS); + + return () => clearTimeout(timer); + }, [hasCompletedInitialAllLoad, isChartLoadingRaw, selectedTimeframe]); + + const hideGuideLineForInitialAllLoader = + selectedTimeframe === 'ALL' && + !hasCompletedInitialAllLoad && + isChartLoaderVisible; + const hasAnyRenderableSeries = + !!activeSeries || + Object.values(timeframeStateByTimeframe).some( + state => !!state?.series?.graphPoints.length, + ); + + // Rare render invalidation races can leave the selected timeframe with no + // renderable series and no active compute. Keep the chart self-healing by + // re-queuing the selected timeframe after a short delay whenever the loader + // is visible and nothing is actively computing it. + useEffect(() => { + if ( + !isChartLoadingRaw || + !inputsReady || + !!selectedTimeframeError || + hasAnalysisPreparationError || + selectedTimeframeState?.isComputing + ) { + return; + } + + const retryTimer = setTimeout(() => { + ensureTimeframeComputed(selectedTimeframe, {prioritize: true}); + }, CHART_LOADER_DELAY_MS); + + return () => clearTimeout(retryTimer); + }, [ + ensureTimeframeComputed, + hasAnalysisPreparationError, + inputsReady, + isChartLoadingRaw, + selectedTimeframe, + selectedTimeframeError, + selectedTimeframeState?.isComputing, + ]); + + const chartColor = lineColor || (theme.dark ? LinkBlue : Action); + const gradientBackgroundColor = + gradientStartColor || (theme.dark ? 'transparent' : White); + + // If there are no chartable snapshots, hide chart UI but still allow callers + // to render any pre-chart badges/content. Preserve the legacy skeleton only + // when there are truly no snapshots yet; non-mainnet-only snapshots should + // not show a perpetual loading placeholder. + if (!hasAnyChartableSnapshots) { + if (showLoaderWhenNoSnapshots && !hasAnySnapshots) { + return ( + <> + {preChartContent ? ( + + {preChartContent} + + ) : null} + + + ); + } + + return preChartContent ? ( + + {preChartContent} + + ) : null; + } + + return ( + <> + {showChangeRow ? ( + + ) : null} + {preChartContent ? ( + + {preChartContent} + + ) : null} + + + + {showTimeframeSelector ? ( + + { + clearSelection(); + onSelectedTimeframeChange?.(timeframe); + setSelectedTimeframe(timeframe); + }} + /> + + ) : null} + + ); +}; + +export default BalanceHistoryChart; diff --git a/src/components/charts/ChartAxisLabel.tsx b/src/components/charts/ChartAxisLabel.tsx new file mode 100644 index 0000000000..e6cdfbcb31 --- /dev/null +++ b/src/components/charts/ChartAxisLabel.tsx @@ -0,0 +1,215 @@ +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import {useTheme} from 'styled-components/native'; +import {BaseText} from '../styled/Text'; +import {formatFiatAmount} from '../../utils/helper-methods'; +import {Slate30, SlateDark} from '../../styles/colors'; +import {isNumberSharedValue, type NumberSharedValue} from './sharedValueGuards'; +import {getChartAxisLabelTranslateX} from './chartLayout'; + +export type ChartAxisLabelProps = { + value: number; + index: number; + width?: number; + arrayLength: number; + quoteCurrency: string; + currencyAbbreviation?: string; + type: 'min' | 'max'; + textColor?: string; + contentOpacity?: number | NumberSharedValue; +}; + +const AnimatedBaseText = Animated.createAnimatedComponent(BaseText); + +export const normalizeChartAxisLabelValue = ( + value: number, + type: 'min' | 'max', +): number => { + if (type === 'min' && (value < 0 || Object.is(value, -0))) { + return 0; + } + + return value; +}; + +const ChartAxisLabel = ({ + value, + index, + width, + arrayLength, + quoteCurrency, + currencyAbbreviation, + type, + textColor, + contentOpacity = 1, +}: ChartAxisLabelProps): React.ReactElement => { + const theme = useTheme(); + const normalizedValue = useMemo( + () => normalizeChartAxisLabelValue(value, type), + [type, value], + ); + + const labelText = useMemo(() => { + return formatFiatAmount(normalizedValue, quoteCurrency, { + currencyAbbreviation, + }); + }, [currencyAbbreviation, normalizedValue, quoteCurrency]); + + // We need an accurate text width to position the label without clipping. + // Measuring via onLayout is correct, but between timeframes the label text can + // change (different number of digits). If we keep using the *previous* width + // until the next onLayout fires, the computed clamped X can be wildly wrong + // (often snapping to an edge) and then "correcting" a frame later. + // + // To avoid that jarring intermediate snap, we track the width *for the + // specific text we measured* and fall back to a cheap estimate for new text + // until its layout is measured. + const [measuredTextLayout, setMeasuredTextLayout] = useState<{ + text: string; + width: number; + }>({text: '', width: 50}); + const [measuredContainerWidth, setMeasuredContainerWidth] = useState< + number | undefined + >(); + const hasMeasuredTranslateRef = useRef(false); + + const estimatedTextWidth = useMemo(() => { + const fontSize = 13; + // Digits and punctuation in RN's default fonts average ~0.55–0.6em. + const avgCharWidth = fontSize * 0.58; + const padding = 8; + const estimated = labelText.length * avgCharWidth + padding; + // Keep the estimate sane; it only needs to avoid edge-clamp snaps. + return Math.min(Math.max(estimated, 40), 220); + }, [labelText]); + + const textWidth = + measuredTextLayout.text === labelText && measuredTextLayout.width > 0 + ? measuredTextLayout.width + : estimatedTextWidth; + const resolvedWidth = + typeof width === 'number' && width > 0 ? width : measuredContainerWidth; + const newTranslateX = + resolvedWidth && resolvedWidth > 0 + ? getChartAxisLabelTranslateX({ + index, + arrayLength, + chartWidth: resolvedWidth, + textWidth, + }) + : 0; + + const translateX = useSharedValue(newTranslateX); + useEffect(() => { + if (!resolvedWidth || resolvedWidth <= 0) { + hasMeasuredTranslateRef.current = false; + return; + } + + if (!hasMeasuredTranslateRef.current) { + translateX.value = newTranslateX; + hasMeasuredTranslateRef.current = true; + return; + } + + if (Math.abs(translateX.value - newTranslateX) < 0.5) { + translateX.value = newTranslateX; + return; + } + + translateX.value = withSpring(newTranslateX, { + mass: 1, + stiffness: 500, + damping: 400, + velocity: 0, + }); + }, [newTranslateX, resolvedWidth, translateX]); + + const translateY = type === 'min' ? 5 : -5; + + const opacity = useSharedValue(0); + useEffect(() => { + opacity.value = withTiming(1, {duration: 800}); + }, [opacity]); + + const labelColor = textColor ?? (theme.dark ? Slate30 : SlateDark); + + const contentOpacityIsSharedValue = isNumberSharedValue(contentOpacity); + const sharedContentOpacity = contentOpacityIsSharedValue + ? contentOpacity + : undefined; + + const contentOpacityNumber = + typeof contentOpacity === 'number' && Number.isFinite(contentOpacity) + ? contentOpacity + : 1; + + const contentOpacityAnimatedStyle = useAnimatedStyle(() => { + const sharedOpacity = sharedContentOpacity?.value; + return { + opacity: + typeof sharedOpacity === 'number' && Number.isFinite(sharedOpacity) + ? sharedOpacity + : contentOpacityNumber, + }; + }, [contentOpacityNumber, sharedContentOpacity]); + + return ( + { + if (typeof width === 'number' && width > 0) { + return; + } + + const nextWidth = Math.round(event.nativeEvent.layout.width); + if (!Number.isFinite(nextWidth) || nextWidth <= 0) { + return; + } + + setMeasuredContainerWidth(prev => + prev === nextWidth ? prev : nextWidth, + ); + }} + style={{ + width: '100%', + flexDirection: 'row', + transform: [{translateY}], + opacity: resolvedWidth && resolvedWidth > 0 ? opacity : 0, + }}> + { + const w = event.nativeEvent.layout.width; + if (!Number.isFinite(w) || w <= 0) { + return; + } + setMeasuredTextLayout(prev => { + // Avoid setState churn for tiny diffs. + if (prev.text === labelText && Math.abs(prev.width - w) < 0.5) { + return prev; + } + return {text: labelText, width: w}; + }); + }}> + + {labelText} + + + + ); +}; + +export default ChartAxisLabel; diff --git a/src/components/charts/ChartChangeRow.tsx b/src/components/charts/ChartChangeRow.tsx new file mode 100644 index 0000000000..12cf4fa5c1 --- /dev/null +++ b/src/components/charts/ChartChangeRow.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import styled from 'styled-components/native'; +import Percentage from '../percentage/Percentage'; + +const PercentRow = styled.View` + flex-direction: row; + align-items: center; + justify-content: center; +`; + +export type ChartChangeRowProps = { + percent: number; + deltaFiatFormatted?: string; + rangeLabel?: string; + style?: StyleProp; +}; + +const ChartChangeRow = ({ + percent, + deltaFiatFormatted, + rangeLabel, + style, +}: ChartChangeRowProps): React.ReactElement => { + return ( + + + + ); +}; + +export default ChartChangeRow; diff --git a/src/components/charts/ChartSelectionDot.tsx b/src/components/charts/ChartSelectionDot.tsx new file mode 100644 index 0000000000..b4f73cdcce --- /dev/null +++ b/src/components/charts/ChartSelectionDot.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import {Circle, Group} from '@shopify/react-native-skia'; +import type {SelectionDotProps} from 'react-native-graph'; +import { + useAnimatedReaction, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +// Shared selection dot renderer used by ExchangeRate and balance history charts +// so chart interactions keep the same animation and styling. +const ChartSelectionDot = ({ + isActive, + color, + circleX, + circleY, +}: SelectionDotProps): React.ReactElement => { + const outerRadius = useSharedValue(0); + const innerRadius = useSharedValue(0); + + useAnimatedReaction( + () => isActive.value, + active => { + outerRadius.value = withSpring(active ? 9 : 0, { + mass: 1, + stiffness: 1000, + damping: 50, + velocity: 0, + }); + innerRadius.value = withSpring(active ? 4 : 0, { + mass: 1, + stiffness: 1000, + damping: 50, + velocity: 0, + }); + }, + [innerRadius, outerRadius], + ); + + return ( + + + + + ); +}; + +export default ChartSelectionDot; diff --git a/src/components/charts/InteractiveLineChart.tsx b/src/components/charts/InteractiveLineChart.tsx new file mode 100644 index 0000000000..cfcef7888b --- /dev/null +++ b/src/components/charts/InteractiveLineChart.tsx @@ -0,0 +1,563 @@ +import React from 'react'; +import {LayoutChangeEvent} from 'react-native'; +import {useIsFocused} from '@react-navigation/native'; +import styled, {useTheme} from 'styled-components/native'; +import {LineGraph, type GraphPoint} from 'react-native-graph'; +import type {SelectionDotProps} from 'react-native-graph'; +import Svg, {Line} from 'react-native-svg'; +import Reanimated, { + Easing, + useAnimatedProps, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import Loader from '../loader/Loader'; +import {Slate, SlateDark} from '../../styles/colors'; +import {isNumberSharedValue, type NumberSharedValue} from './sharedValueGuards'; + +const ChartContainer = styled.View` + width: 100%; +`; + +const ChartInner = styled.View` + position: relative; + align-items: stretch; + justify-content: center; + height: 220px; +`; + +const ChartLoaderOverlay = styled.View` + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + justify-content: center; + align-items: center; +`; + +const AnimatedSvgLine = Reanimated.createAnimatedComponent(Line); + +const FIRST_POINT_GUIDE_LINE_DASH_LENGTH = 2.5; +const FIRST_POINT_GUIDE_LINE_GAP_LENGTH = 4.1; +const FIRST_POINT_GUIDE_LINE_SVG_HEIGHT = 4; + +export type InteractiveLineChartAxisLabelProps = { + width?: number; +}; + +export type InteractiveLineChartProps = { + points: GraphPoint[]; + color: string; + gradientFillColors: [string, string]; + width?: number; + lineThickness?: number; + /** + * If the chart is being scaled by an ancestor transform, pass that scale here. + * + * We use this to compensate stroke widths (and dash pattern) so they appear + * visually constant even while the chart view itself is being scaled. + */ + strokeScale?: number | NumberSharedValue; + /** + * Optional lower bound for `strokeScale`. + * + * When the chart is animated with an ancestor scale transform, the + * compensated stroke can become much thicker than its base thickness. + * `react-native-graph` computes the path using a static `verticalPadding`, so + * if we know the smallest scale the animation can reach we can reserve enough + * padding up-front to avoid edge clipping without animating layout. + */ + minStrokeScale?: number; + isLoading?: boolean; + hideLineWhileLoading?: boolean; + enablePanGesture?: boolean; + panGestureDelay?: number; + animated?: boolean; + SelectionDot?: React.ComponentType; + TopAxisLabel?: React.ComponentType; + BottomAxisLabel?: React.ComponentType; + onGestureStart?: () => void; + onGestureEnd?: () => void; + onPointSelected?: (point: GraphPoint) => void; + showFirstPointGuideLine?: boolean; + firstPointGuideLineColor?: string; +}; + +type AxisLabelRendererProps = Record; +type AxisLabelRenderer = ( + props?: AxisLabelRendererProps, +) => React.ReactElement | null; +type SvgLineAnimatedProps = Partial>; + +const clonePointsForGraph = ( + points: GraphPoint[], + _refreshInputs: readonly [string, number, number], +): GraphPoint[] => { + return points.slice(); +}; + +const InteractiveLineChart = ({ + points, + color, + gradientFillColors, + width, + lineThickness, + strokeScale, + minStrokeScale, + isLoading, + hideLineWhileLoading = false, + enablePanGesture = true, + panGestureDelay = 100, + animated = true, + SelectionDot, + TopAxisLabel, + BottomAxisLabel, + onGestureEnd, + onGestureStart, + onPointSelected, + showFirstPointGuideLine = false, + firstPointGuideLineColor, +}: InteractiveLineChartProps): React.ReactElement => { + const theme = useTheme(); + const isFocused = useIsFocused(); + const graphHeight = 200; + const graphMarginTop = 10; + const axisLabelPadding = 20; + const axisRowHeight = 17; + + const [chartWidth, setChartWidth] = React.useState(); + const [lineGraphLayout, setLineGraphLayout] = React.useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + const firstPointGuideLineTop = useSharedValue(0); + const isGuideLineTopInitializedRef = React.useRef(false); + const resolvedChartWidth = + typeof width === 'number' && width > 0 ? width : chartWidth; + const resolvedChartWidthRef = React.useRef(resolvedChartWidth); + const topAxisLabelRef = React.useRef(TopAxisLabel); + const bottomAxisLabelRef = React.useRef(BottomAxisLabel); + + resolvedChartWidthRef.current = resolvedChartWidth; + topAxisLabelRef.current = TopAxisLabel; + bottomAxisLabelRef.current = BottomAxisLabel; + + const effectiveLineThickness = + typeof lineThickness === 'number' ? lineThickness : theme.dark ? 2 : 4; + + const strokeScaleIsSharedValue = isNumberSharedValue(strokeScale); + const sharedStrokeScale = strokeScaleIsSharedValue ? strokeScale : undefined; + + const strokeScaleNumber = + typeof strokeScale === 'number' && Number.isFinite(strokeScale) + ? strokeScale + : 1; + const safeStrokeScaleNumber = strokeScaleNumber > 0 ? strokeScaleNumber : 1; + const lineThicknessCompensationExponent = strokeScaleIsSharedValue ? 0.9 : 1; + + /** + * If an ancestor is scaling this chart view (e.g. during a collapse/expand + * animation), compensate stroke widths so the chart line & dash pattern keep + * a constant visual thickness. + */ + const strokeScaleValue = useDerivedValue(() => { + 'worklet'; + + // Support callers passing either a number or a Reanimated shared/derived value. + const scale = sharedStrokeScale?.value ?? strokeScaleNumber; + return Number.isFinite(scale) ? scale : 1; + }, [sharedStrokeScale, strokeScaleNumber]); + + // IMPORTANT: react-native-graph's LineGraph is implemented as a composite + // component that renders Skia primitives. Reanimated's `animatedProps` + // cannot update its JS props without a React re-render. + // + // However, RN Skia *does* support Reanimated shared/derived values directly. + // By passing a derived value as `lineThickness`, the underlying `` updates on the UI thread without forcing + // React re-renders (i.e. no jank). + const compensatedLineThickness = useDerivedValue(() => { + 'worklet'; + const scale = strokeScaleValue.value; + // Guard against accidental 0/negative scales. + const safeScale = scale > 0 ? scale : 1; + return ( + effectiveLineThickness / + Math.pow(safeScale, lineThicknessCompensationExponent) + ); + }, [ + effectiveLineThickness, + lineThicknessCompensationExponent, + strokeScaleValue, + ]); + + // Prefer a plain number when we don't need dynamic compensation. This keeps + // behavior compatible with non-animated graph implementations. + const lineThicknessForGraph: number | NumberSharedValue = + strokeScaleIsSharedValue + ? compensatedLineThickness + : effectiveLineThickness / + Math.pow(safeStrokeScaleNumber, lineThicknessCompensationExponent); + + const firstPointGuideLineAnimatedProps = + useAnimatedProps(() => { + const scale = strokeScaleValue.value; + const safeScale = scale > 0 ? scale : 1; + + return { + // Keep the dash thickness constant under the parent scale. + strokeWidth: 1 / safeScale, + // Keep dash + gap lengths constant under the parent scale. + strokeDasharray: [ + FIRST_POINT_GUIDE_LINE_DASH_LENGTH / safeScale, + FIRST_POINT_GUIDE_LINE_GAP_LENGTH / safeScale, + ], + }; + }, [strokeScaleValue]); + + /** + * THEME SWITCH BEHAVIOR (important) + * + * Requirements: + * - Theme changes must immediately update chart color + thickness. + * - Theme changes must not trigger any visible animation. + * - Timeframe switches (points changes) should continue to animate. + * + * Why theme updates can look "inconsistent": + * - `react-native-graph` renders via Skia. When the chart's screen is not + * focused (e.g. you're in Settings), React can still re-render props, but + * the underlying native/Skia view may be detached/frozen by navigation. + * - In those cases, the color update can be "lost" visually until *some* + * later event forces the graph to recompute/refresh (e.g. timeframe + * changes). + * + * Fix strategy: + * 1) Keep path geometry stable across light/dark so thickness changes don't + * alter the computed path (avoids the visible "scale/morph"). + * 2) Force a cheap redraw by passing a new `points` array reference (same + * values) when: + * - the style signature changes (theme switch), and + * - the screen becomes focused again after a theme switch that + * happened while unfocused (so the redraw occurs while visible). + */ + + // Keep geometry stable across theme switches (AnimatedLineGraph defaults + // verticalPadding to lineThickness). When the caller knows the minimum scale + // the chart will animate down to, also reserve enough static headroom for the + // thickest compensated stroke so the graph never clips at the top/bottom. + const resolvedMinStrokeScale = + typeof minStrokeScale === 'number' && minStrokeScale > 0 + ? Math.min(minStrokeScale, 1) + : strokeScaleIsSharedValue + ? 1 + : Math.min(safeStrokeScaleNumber, 1); + + const maxCompensatedLineThickness = + effectiveLineThickness / + Math.pow(resolvedMinStrokeScale, lineThicknessCompensationExponent); + + const stableVerticalPadding = Math.max( + // max thickness used across themes (light: 4, dark: 2) + 4, + typeof lineThickness === 'number' ? lineThickness : 0, + Math.ceil(maxCompensatedLineThickness), + ); + const stableHorizontalPadding = 0; + + // A compact signature of everything that should trigger a redraw when the + // graph's visual style or path geometry changes. + const styleSignature = `${color}|${gradientFillColors[0]}|${gradientFillColors[1]}|${effectiveLineThickness}|${stableVerticalPadding}`; + + /** + * If the theme changes while this screen is NOT focused, we want to trigger a + * redraw the moment it becomes focused again. + */ + const lastFocusedStyleSignatureRef = React.useRef(null); + const [focusRefreshNonce, setFocusRefreshNonce] = React.useState(0); + + React.useEffect(() => { + if (!isFocused) { + return; + } + + const prev = lastFocusedStyleSignatureRef.current; + + // Update the ref so it always represents the currently-focused signature. + lastFocusedStyleSignatureRef.current = styleSignature; + + // If we are focused AND the signature differs from the last time we were + // focused, a theme/style change happened while we were away. Force a redraw + // now that we're visible again. + if (prev != null && prev !== styleSignature) { + setFocusRefreshNonce(n => n + 1); + } + }, [isFocused, styleSignature]); + + /** + * In addition to focus changes, "reattaching" the view can happen without a + * focus transition in some navigation setups. We treat the first layout after + * a theme/style change as another opportunity to force a redraw. + */ + const lastLayoutStyleSignatureRef = React.useRef(null); + const [layoutRefreshNonce, setLayoutRefreshNonce] = React.useState(0); + + const onChartLayout = React.useCallback( + ({nativeEvent: {layout}}: LayoutChangeEvent) => { + if (!(typeof width === 'number' && width > 0)) { + const nextWidth = Math.round(layout.width); + if (Number.isFinite(nextWidth) && nextWidth > 0) { + setChartWidth(prev => (prev === nextWidth ? prev : nextWidth)); + } + } + + const prev = lastLayoutStyleSignatureRef.current; + lastLayoutStyleSignatureRef.current = styleSignature; + + if (prev != null && prev !== styleSignature) { + setLayoutRefreshNonce(n => n + 1); + } + }, + [styleSignature, width], + ); + + // Force a new points array reference whenever either: + // - data changes (timeframe switch -> animation desired), + // - style changes (theme switch), + // - we regain focus after a theme switch (ensures redraw is visible), + // - layout happens after a theme switch (handles detach/reattach cases). + const pointsForGraph = React.useMemo( + () => + clonePointsForGraph(points, [ + styleSignature, + focusRefreshNonce, + layoutRefreshNonce, + ]), + [points, styleSignature, focusRefreshNonce, layoutRefreshNonce], + ); + const hasDrawablePoints = pointsForGraph.length >= 2; + const ResolvedTopAxisLabel = React.useCallback(props => { + const CurrentAxisLabel = topAxisLabelRef.current; + if (!CurrentAxisLabel) { + return null; + } + + return ( + + ); + }, []); + const ResolvedBottomAxisLabel = React.useCallback( + props => { + const CurrentAxisLabel = bottomAxisLabelRef.current; + if (!CurrentAxisLabel) { + return null; + } + + return ( + + ); + }, + [], + ); + + const firstPointGuideLine = React.useMemo(() => { + if ( + !showFirstPointGuideLine || + !pointsForGraph.length || + !lineGraphLayout + ) { + return null; + } + + let minValue = Number.POSITIVE_INFINITY; + let maxValue = Number.NEGATIVE_INFINITY; + for (const point of pointsForGraph) { + const value = Number(point?.value); + if (!Number.isFinite(value)) { + continue; + } + if (value < minValue) { + minValue = value; + } + if (value > maxValue) { + maxValue = value; + } + } + + if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) { + return null; + } + + const firstPointValue = Number.isFinite(pointsForGraph[0]?.value) + ? Number(pointsForGraph[0]?.value) + : minValue; + const topAxisInset = TopAxisLabel ? axisLabelPadding + axisRowHeight : 0; + const bottomAxisInset = BottomAxisLabel + ? axisLabelPadding + axisRowHeight + : 0; + + // Match react-native-graph's internal canvas sizing and Y transform. + const canvasHeight = Math.max( + 0, + lineGraphLayout.height - topAxisInset - bottomAxisInset, + ); + const drawingHeight = Math.max(0, canvasHeight - stableVerticalPadding * 2); + + const yPositionInRange = + maxValue === minValue + ? 0.5 + : (firstPointValue - minValue) / (maxValue - minValue); + const yInRange = Math.floor(drawingHeight * yPositionInRange); + const y = drawingHeight - yInRange + stableVerticalPadding; + const top = lineGraphLayout.y + topAxisInset + y; + + return { + left: lineGraphLayout.x + stableHorizontalPadding, + width: Math.max(0, lineGraphLayout.width - stableHorizontalPadding), + top, + color: firstPointGuideLineColor ?? (theme.dark ? Slate : SlateDark), + }; + }, [ + BottomAxisLabel, + TopAxisLabel, + axisLabelPadding, + axisRowHeight, + firstPointGuideLineColor, + lineGraphLayout, + pointsForGraph, + showFirstPointGuideLine, + stableHorizontalPadding, + stableVerticalPadding, + theme.dark, + ]); + + const firstPointGuideLineTopTarget = + firstPointGuideLine != null + ? firstPointGuideLine.top - FIRST_POINT_GUIDE_LINE_SVG_HEIGHT / 2 + : null; + + const firstPointGuideLineAnimatedStyle = useAnimatedStyle(() => ({ + top: firstPointGuideLineTop.value, + })); + + React.useEffect(() => { + if (firstPointGuideLineTopTarget == null) { + isGuideLineTopInitializedRef.current = false; + return; + } + + if (!isGuideLineTopInitializedRef.current) { + firstPointGuideLineTop.value = firstPointGuideLineTopTarget; + isGuideLineTopInitializedRef.current = true; + return; + } + + firstPointGuideLineTop.value = withTiming(firstPointGuideLineTopTarget, { + duration: 260, + easing: Easing.out(Easing.cubic), + }); + }, [firstPointGuideLineTop, firstPointGuideLineTopTarget]); + + const chartInner = ( + + {hasDrawablePoints ? ( + { + const next = { + x: layout.x, + y: layout.y, + width: layout.width, + height: layout.height, + }; + setLineGraphLayout(prev => + prev && + prev.x === next.x && + prev.y === next.y && + prev.width === next.width && + prev.height === next.height + ? prev + : next, + ); + }} + style={{ + width: resolvedChartWidth ?? '100%', + height: graphHeight, + marginTop: graphMarginTop, + opacity: isLoading ? (hideLineWhileLoading ? 0 : 0.25) : 1, + }} + /> + ) : null} + {firstPointGuideLine ? ( + + + + + + ) : null} + {isLoading ? ( + + + + ) : null} + + ); + + return ( + + {chartInner} + + ); +}; + +export default InteractiveLineChart; diff --git a/src/components/charts/TimeframeSelector.tsx b/src/components/charts/TimeframeSelector.tsx new file mode 100644 index 0000000000..e5b752ddfe --- /dev/null +++ b/src/components/charts/TimeframeSelector.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import styled from 'styled-components/native'; +import {TouchableOpacity} from '@components/base/TouchableOpacity'; +import {ActiveOpacity} from '../styled/Containers'; +import {BaseText} from '../styled/Text'; +import { + Action, + LightBlue, + LinkBlue, + Midnight, + Slate30, + SlateDark, +} from '../../styles/colors'; + +export type TimeframeSelectorOption = { + value: T; + label: string; + testID?: string; +}; + +type Props = { + options: Array>; + selected: T; + onSelect: (value: T) => void; + width?: number; + horizontalInset?: string; +}; + +const TimeframeContainer = styled.View<{$horizontalInset?: string}>` + margin-top: 5px; + width: 100%; + padding: 0 ${({$horizontalInset = '0'}) => $horizontalInset}; +`; + +const TimeframeRow = styled.View` + flex-direction: row; + justify-content: space-between; + align-self: center; + width: 100%; +`; + +const TimeframeHitSlop = {top: 10, bottom: 10, left: 10, right: 10} as const; + +type TimeframeSelectorStyledProps = {$active: boolean}; + +const TimeframePill = styled(TouchableOpacity)` + height: 34px; + min-width: 44px; + padding: 0 12px; + border-radius: 18px; + align-items: center; + justify-content: center; + background-color: ${({theme, $active}) => + $active ? (theme.dark ? Midnight : LightBlue) : 'transparent'}; +`; + +const TimeframeText = styled(BaseText)` + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: ${({theme, $active}) => + $active + ? theme.dark + ? LinkBlue + : Action + : theme.dark + ? Slate30 + : SlateDark}; +`; + +export const TimeframeSelector = ({ + options, + selected, + onSelect, + width, + horizontalInset, +}: Props): React.ReactElement => { + return ( + + + {options.map(opt => { + const active = opt.value === selected; + return ( + onSelect(opt.value)} + testID={opt.testID}> + {opt.label} + + ); + })} + + + ); +}; + +export default TimeframeSelector; diff --git a/src/components/charts/balanceHistoryChartDataPrep.ts b/src/components/charts/balanceHistoryChartDataPrep.ts new file mode 100644 index 0000000000..34462031dc --- /dev/null +++ b/src/components/charts/balanceHistoryChartDataPrep.ts @@ -0,0 +1,152 @@ +import type { + FiatRateInterval, + FiatRateSeriesCache, +} from '../../store/rate/rate.models'; +import { + getFiatRateSeriesAssetKey, + getFiatRateSeriesCacheKey, +} from '../../store/rate/rate.models'; +import type { + BalanceSnapshot, + BalanceSnapshotsByWalletId, +} from '../../store/portfolio/portfolio.models'; +import type {Wallet} from '../../store/wallet/wallet.models'; +import { + getPortfolioWalletChainLower, + getPortfolioWalletCurrencyAbbreviation, + getPortfolioWalletTokenAddressNormalized, +} from '../../utils/portfolio/assets'; +import {normalizeFiatRateSeriesCoin} from '../../utils/portfolio/core/pnl/rates'; + +const EMPTY_BALANCE_SNAPSHOTS: BalanceSnapshot[] = []; + +export type BalanceHistoryChartRateFetchAsset = { + coinForCacheCheck: string; + chain?: string; + tokenAddress?: string; +}; + +export const buildBalanceHistoryChartRateFetchAssets = ( + wallets?: Wallet[], +): BalanceHistoryChartRateFetchAsset[] => { + const assets: BalanceHistoryChartRateFetchAsset[] = []; + const seenAssetKeys = new Set(); + + for (const wallet of wallets || []) { + const coinForCacheCheck = normalizeFiatRateSeriesCoin( + getPortfolioWalletCurrencyAbbreviation(wallet), + ); + if (!coinForCacheCheck) { + continue; + } + + const chainLower = getPortfolioWalletChainLower(wallet); + const tokenAddress = getPortfolioWalletTokenAddressNormalized(wallet); + const chain = tokenAddress ? chainLower || undefined : undefined; + const assetKey = getFiatRateSeriesAssetKey(coinForCacheCheck, { + chain, + tokenAddress, + }); + if (!assetKey || seenAssetKeys.has(assetKey)) { + continue; + } + + seenAssetKeys.add(assetKey); + assets.push({ + coinForCacheCheck, + chain, + tokenAddress, + }); + } + + return assets; +}; + +export const buildBalanceHistoryChartRelevantRateCacheAssets = ( + rateFetchAssets: BalanceHistoryChartRateFetchAsset[], +): Array<{ + coin: string; + chain?: string; + tokenAddress?: string; +}> => { + return rateFetchAssets.map(asset => ({ + coin: asset.coinForCacheCheck, + chain: asset.chain, + tokenAddress: asset.tokenAddress, + })); +}; + +export const buildBalanceHistoryChartPrepFiatRateSeriesCacheKeys = (args: { + quoteCurrency: string; + scopedSnapshotsByWalletId: BalanceSnapshotsByWalletId; + prepIntervals: FiatRateInterval[]; +}): string[] => { + const targetQuoteCurrency = (args.quoteCurrency || '').toUpperCase(); + if (!targetQuoteCurrency) { + return []; + } + + const quoteCurrencies = new Set(); + let needsTargetQuoteCurrencySeries = false; + + for (const snapshots of Object.values(args.scopedSnapshotsByWalletId || {})) { + for (const snapshot of snapshots || EMPTY_BALANCE_SNAPSHOTS) { + const snapshotQuoteCurrency = ( + snapshot?.quoteCurrency || '' + ).toUpperCase(); + if ( + !snapshotQuoteCurrency || + snapshotQuoteCurrency === targetQuoteCurrency + ) { + continue; + } + + quoteCurrencies.add(snapshotQuoteCurrency); + needsTargetQuoteCurrencySeries = true; + } + } + + if (!needsTargetQuoteCurrencySeries) { + return []; + } + + quoteCurrencies.add(targetQuoteCurrency); + + const keys = new Set(); + for (const fiatCode of Array.from(quoteCurrencies).sort((a, b) => + a.localeCompare(b), + )) { + for (const interval of args.prepIntervals) { + keys.add(getFiatRateSeriesCacheKey(fiatCode, 'btc', interval)); + } + } + + return Array.from(keys).sort((a, b) => a.localeCompare(b)); +}; + +export const getLatestFiatRateSeriesPointTs = ( + cache?: FiatRateSeriesCache, +): number | undefined => { + if (!cache) { + return undefined; + } + + let maxTs = 0; + for (const cacheKey in cache) { + if (!Object.prototype.hasOwnProperty.call(cache, cacheKey)) { + continue; + } + + const points = cache?.[cacheKey]?.points; + if (!Array.isArray(points) || !points.length) { + continue; + } + + const lastTs = Number(points[points.length - 1]?.ts); + if (Number.isFinite(lastTs) && lastTs > maxTs) { + maxTs = lastTs; + } + } + + return maxTs > 0 ? maxTs : undefined; +}; diff --git a/src/components/charts/balanceHistoryChartHydration.ts b/src/components/charts/balanceHistoryChartHydration.ts new file mode 100644 index 0000000000..fdda55f20c --- /dev/null +++ b/src/components/charts/balanceHistoryChartHydration.ts @@ -0,0 +1,95 @@ +import type {FiatRateInterval} from '../../store/rate/rate.models'; +import type { + CachedBalanceChartTimeframe, + HistoricalRateDependencyMeta, +} from '../../store/portfolio-charts'; +import { + patchCachedLatestPointWithSpotRates, + type CachedTimeframeStatus, +} from '../../utils/portfolio/chartCache'; + +export type HydratedBalanceChartTimeframeUpdate = { + series: TSeries; + seriesRevision: string; +}; + +export const getEffectiveCachedBalanceChartTimeframe = (args: { + cachedTimeframe: CachedBalanceChartTimeframe; + status: CachedTimeframeStatus; + currentSpotRatesByRateKey: Record; +}): CachedBalanceChartTimeframe => { + return args.status === 'patchable' + ? patchCachedLatestPointWithSpotRates({ + cachedTimeframe: args.cachedTimeframe, + currentSpotRatesByRateKey: args.currentSpotRatesByRateKey, + }) + : args.cachedTimeframe; +}; + +export const buildHydratedBalanceChartTimeframes = (args: { + timeframes?: Partial>; + timeframeOrder: FiatRateInterval[]; + selectedTimeframe: FiatRateInterval; + cachedStatusByTimeframe: Partial< + Record + >; + currentSpotRatesByRateKey: Record; + deserializeTimeframe: ( + cachedTimeframe: CachedBalanceChartTimeframe, + ) => TSeries; + getTimeframeRevision: ( + timeframe: FiatRateInterval, + historicalRateDeps: HistoricalRateDependencyMeta[], + ) => string; +}): { + patchedTimeframes: CachedBalanceChartTimeframe[]; + hydratedTimeframes: Partial< + Record> + >; + selectedHydratedSeries?: TSeries; +} => { + const patchedTimeframes: CachedBalanceChartTimeframe[] = []; + const hydratedTimeframes: Partial< + Record> + > = {}; + let selectedHydratedSeries: TSeries | undefined; + + for (const timeframe of args.timeframeOrder) { + const cachedTimeframe = args.timeframes?.[timeframe]; + if (!cachedTimeframe) { + continue; + } + + const status = args.cachedStatusByTimeframe[timeframe] || 'missing'; + const effectiveCachedTimeframe = getEffectiveCachedBalanceChartTimeframe({ + cachedTimeframe, + status, + currentSpotRatesByRateKey: args.currentSpotRatesByRateKey, + }); + if (effectiveCachedTimeframe !== cachedTimeframe) { + patchedTimeframes.push(effectiveCachedTimeframe); + } + + const series = args.deserializeTimeframe(effectiveCachedTimeframe); + hydratedTimeframes[timeframe] = { + series, + seriesRevision: + status === 'fresh' || status === 'patchable' + ? args.getTimeframeRevision( + timeframe, + effectiveCachedTimeframe.historicalRateDeps, + ) + : `stale:${effectiveCachedTimeframe.builtAt}:${timeframe}`, + }; + + if (timeframe === args.selectedTimeframe) { + selectedHydratedSeries = series; + } + } + + return { + patchedTimeframes, + hydratedTimeframes, + selectedHydratedSeries, + }; +}; diff --git a/src/components/charts/balanceHistoryChartOrchestration.ts b/src/components/charts/balanceHistoryChartOrchestration.ts new file mode 100644 index 0000000000..9df37908f6 --- /dev/null +++ b/src/components/charts/balanceHistoryChartOrchestration.ts @@ -0,0 +1,349 @@ +import type {FiatRateInterval} from '../../store/rate/rate.models'; +import type {CachedTimeframeStatus} from '../../utils/portfolio/chartCache'; + +export type TimeframeChartState = { + series?: TSeries; + seriesRevision?: string; + isComputing?: boolean; + lastAttemptRevision?: string; + lastError?: string; + lastResolvedChangeRowData?: TChangeRowData; +}; + +export type TimeframeChartStateByTimeframe = Partial< + Record> +>; + +export type BalanceHistoryChartOrchestrationState = { + generation: number; + byTimeframe: TimeframeChartStateByTimeframe; +}; + +type HydratedTimeframeUpdate = { + series: TSeries; + seriesRevision: string; +}; + +type MergeHydratedSeriesAction = { + type: 'mergeHydratedSeries'; + updates: Partial>>; +}; + +type StartComputeAction = { + type: 'startCompute'; + timeframe: FiatRateInterval; + attemptRevision: string; + generation: number; +}; + +type ResolveComputeAction = { + type: 'resolveCompute'; + timeframe: FiatRateInterval; + attemptRevision: string; + series: TSeries; + seriesRevision: string; + generation: number; +}; + +type RejectComputeAction = { + type: 'rejectCompute'; + timeframe: FiatRateInterval; + attemptRevision: string; + error: string; + generation: number; +}; + +type SetResolvedChangeRowDataAction = { + type: 'setResolvedChangeRowData'; + timeframe: FiatRateInterval; + data: TChangeRowData; +}; + +type AdvanceGenerationAction = { + type: 'advanceGeneration'; + generation: number; +}; + +type ResetAllAction = { + type: 'resetAll'; + generation: number; +}; + +export type BalanceHistoryChartOrchestrationAction = + | MergeHydratedSeriesAction + | StartComputeAction + | ResolveComputeAction + | RejectComputeAction + | SetResolvedChangeRowDataAction + | AdvanceGenerationAction + | ResetAllAction; + +export const createInitialBalanceHistoryChartOrchestrationState = < + TSeries, + TChangeRowData, +>(): BalanceHistoryChartOrchestrationState => ({ + generation: 0, + byTimeframe: {}, +}); + +const updateTimeframeEntry = ( + state: BalanceHistoryChartOrchestrationState, + timeframe: FiatRateInterval, + updater: ( + entry: TimeframeChartState, + ) => TimeframeChartState, +): BalanceHistoryChartOrchestrationState => { + const current = state.byTimeframe[timeframe] || {}; + const next = updater(current); + + if (next === current) { + return state; + } + + return { + ...state, + byTimeframe: { + ...state.byTimeframe, + [timeframe]: next, + }, + }; +}; + +export const balanceHistoryChartOrchestrationReducer = < + TSeries, + TChangeRowData, +>( + state: BalanceHistoryChartOrchestrationState, + action: BalanceHistoryChartOrchestrationAction, +): BalanceHistoryChartOrchestrationState => { + switch (action.type) { + case 'mergeHydratedSeries': { + let didChange = false; + const nextByTimeframe = {...state.byTimeframe}; + + for (const timeframe of Object.keys( + action.updates, + ) as FiatRateInterval[]) { + const update = action.updates[timeframe]; + if (!update) { + continue; + } + + const current = state.byTimeframe[timeframe] || {}; + if ( + current.series === update.series && + current.seriesRevision === update.seriesRevision + ) { + continue; + } + + nextByTimeframe[timeframe] = { + ...current, + series: update.series, + seriesRevision: update.seriesRevision, + }; + didChange = true; + } + + return didChange + ? { + ...state, + byTimeframe: nextByTimeframe, + } + : state; + } + + case 'startCompute': + if (action.generation !== state.generation) { + return state; + } + return updateTimeframeEntry(state, action.timeframe, current => ({ + ...current, + isComputing: true, + lastAttemptRevision: action.attemptRevision, + lastError: undefined, + })); + + case 'resolveCompute': + if (action.generation !== state.generation) { + return state; + } + return updateTimeframeEntry(state, action.timeframe, current => ({ + ...current, + series: action.series, + seriesRevision: action.seriesRevision, + isComputing: false, + lastAttemptRevision: action.attemptRevision, + lastError: undefined, + })); + + case 'rejectCompute': + if (action.generation !== state.generation) { + return state; + } + return updateTimeframeEntry(state, action.timeframe, current => ({ + ...current, + isComputing: false, + lastAttemptRevision: action.attemptRevision, + lastError: action.error, + })); + + case 'setResolvedChangeRowData': + return updateTimeframeEntry(state, action.timeframe, current => { + if (current.lastResolvedChangeRowData === action.data) { + return current; + } + + return { + ...current, + lastResolvedChangeRowData: action.data, + }; + }); + + case 'advanceGeneration': { + let didChange = state.generation !== action.generation; + const nextByTimeframe = {...state.byTimeframe}; + + for (const timeframe of Object.keys( + state.byTimeframe, + ) as FiatRateInterval[]) { + const current = state.byTimeframe[timeframe]; + if (!current?.isComputing) { + continue; + } + + nextByTimeframe[timeframe] = { + ...current, + isComputing: false, + }; + didChange = true; + } + + return didChange + ? { + generation: action.generation, + byTimeframe: nextByTimeframe, + } + : state; + } + + case 'resetAll': + return { + generation: action.generation, + byTimeframe: {}, + }; + + default: + return state; + } +}; + +type TimeframeComputeRetryPolicy = + | 'retry_interrupted_attempts' + | 'suppress_after_attempt'; + +export type TimeframeComputeDisposition = + | {shouldQueue: true} + | { + shouldQueue: false; + reason: + | 'cached' + | 'current_series' + | 'successful_attempt' + | 'already_computing' + | 'no_snapshots' + | 'inputs_not_ready' + | 'retry_suppressed_after_error' + | 'retry_suppressed_after_attempt'; + }; + +export const getTimeframeComputeDisposition = (args: { + cachedStatus: CachedTimeframeStatus; + timeframeRevision: string; + attemptRevision: string; + timeframeState?: TimeframeChartState; + hasAnySnapshots: boolean; + inputsReady: boolean; + retryPolicy: TimeframeComputeRetryPolicy; +}): TimeframeComputeDisposition => { + const state = args.timeframeState; + + if (args.cachedStatus === 'fresh' || args.cachedStatus === 'patchable') { + return {shouldQueue: false, reason: 'cached'}; + } + + if (state?.series && state.seriesRevision === args.timeframeRevision) { + return {shouldQueue: false, reason: 'current_series'}; + } + + if ( + state?.series && + state.lastAttemptRevision === args.attemptRevision && + !state.lastError + ) { + return {shouldQueue: false, reason: 'successful_attempt'}; + } + + if ( + state?.isComputing && + state.lastAttemptRevision === args.attemptRevision + ) { + return {shouldQueue: false, reason: 'already_computing'}; + } + + if (!args.hasAnySnapshots) { + return {shouldQueue: false, reason: 'no_snapshots'}; + } + + if (!args.inputsReady) { + return {shouldQueue: false, reason: 'inputs_not_ready'}; + } + + if (state?.lastAttemptRevision === args.attemptRevision) { + if (state.lastError) { + return { + shouldQueue: false, + reason: 'retry_suppressed_after_error', + }; + } + + if (args.retryPolicy === 'suppress_after_attempt') { + return { + shouldQueue: false, + reason: 'retry_suppressed_after_attempt', + }; + } + } + + return {shouldQueue: true}; +}; + +export const selectComputedSeriesForAttempt = (args: { + timeframeState?: TimeframeChartState; + timeframeRevision: string; + attemptRevision: string; +}): TSeries | undefined => { + const state = args.timeframeState; + if (!state?.series) { + return undefined; + } + + if (state.seriesRevision === args.timeframeRevision) { + return state.series; + } + + if (state.lastAttemptRevision === args.attemptRevision && !state.lastError) { + return state.series; + } + + return undefined; +}; + +export const selectTimeframeErrorForAttempt = (args: { + timeframeState?: TimeframeChartState; + attemptRevision: string; +}): string | undefined => { + return args.timeframeState?.lastAttemptRevision === args.attemptRevision + ? args.timeframeState.lastError + : undefined; +}; diff --git a/src/components/charts/balanceHistoryChartRateCacheRevision.ts b/src/components/charts/balanceHistoryChartRateCacheRevision.ts new file mode 100644 index 0000000000..144a7c3094 --- /dev/null +++ b/src/components/charts/balanceHistoryChartRateCacheRevision.ts @@ -0,0 +1,89 @@ +import type { + FiatRateSeriesAssetIdentity, + FiatRateInterval, + FiatRateSeriesCache, +} from '../../store/rate/rate.models'; +import {getFiatRateSeriesCacheKey} from '../../store/rate/rate.models'; +import {getSeriesIntervalForFiatTimeframe} from './fiatTimeframes'; + +export const getRelevantFiatRateSeriesCacheKeys = (args: { + fiatCode: string; + coins?: string[]; + assets?: FiatRateSeriesAssetIdentity[]; + timeframes: FiatRateInterval[]; +}): string[] => { + const keys = new Set(); + + const assets = + args.assets || + (args.coins || []).map(coin => ({ + coin, + })); + + for (const asset of assets) { + const coin = typeof asset?.coin === 'string' ? asset.coin : ''; + if (!coin.trim()) { + continue; + } + + for (const timeframe of args.timeframes || []) { + keys.add( + getFiatRateSeriesCacheKey( + args.fiatCode, + coin, + getSeriesIntervalForFiatTimeframe(timeframe), + { + chain: asset?.chain, + tokenAddress: asset?.tokenAddress, + }, + ), + ); + } + } + + return Array.from(keys).sort((a, b) => a.localeCompare(b)); +}; + +export const computeFiatRateSeriesCacheRevision = (args: { + fiatRateSeriesCache?: FiatRateSeriesCache; + relevantKeys: string[]; +}): string => { + let keysPresentCount = 0; + let maxFetchedOn = 0; + const cache = args.fiatRateSeriesCache; + const relevantKeys = Array.from(new Set(args.relevantKeys || [])).sort( + (a, b) => a.localeCompare(b), + ); + const fetchedOnSignatureParts: string[] = []; + + for (const key of relevantKeys) { + if (!Object.prototype.hasOwnProperty.call(cache || {}, key)) { + fetchedOnSignatureParts.push(`${key}:missing`); + continue; + } + + keysPresentCount += 1; + + const entry = cache?.[key]; + const fetchedOn = entry?.fetchedOn; + const fetchedOnSig = + typeof fetchedOn === 'number' && Number.isFinite(fetchedOn) + ? fetchedOn + : 'na'; + const points = Array.isArray(entry?.points) ? entry.points : undefined; + const lastPointTs = points?.length + ? Number(points[points.length - 1]?.ts) + : NaN; + const lastTsSig = Number.isFinite(lastPointTs) ? lastPointTs : 'na'; + fetchedOnSignatureParts.push(`${key}:${fetchedOnSig}:${lastTsSig}`); + + if (typeof fetchedOn === 'number' && Number.isFinite(fetchedOn)) { + maxFetchedOn = Math.max(maxFetchedOn, fetchedOn); + } + } + + return [ + `${keysPresentCount}:${maxFetchedOn}`, + fetchedOnSignatureParts.join('|'), + ].join(':'); +}; diff --git a/src/components/charts/balanceHistoryChartSelection.ts b/src/components/charts/balanceHistoryChartSelection.ts new file mode 100644 index 0000000000..862179fb79 --- /dev/null +++ b/src/components/charts/balanceHistoryChartSelection.ts @@ -0,0 +1,97 @@ +import type {GraphPoint} from 'react-native-graph'; +import type {HydratedBalanceChartSeries} from '../../utils/portfolio/chartCache'; +import type {PnlAnalysisPoint} from '../../utils/portfolio/core/pnl/analysis'; +import {formatFiatAmount} from '../../utils/helper-methods'; + +export type ChangeRowData = { + percent: number; + deltaFiatFormatted?: string; + rangeLabel?: string; +}; + +type SeriesLike = Pick< + HydratedBalanceChartSeries, + 'analysisPoints' | 'pointByTimestamp' +>; + +export const getSelectedBalanceHistoryAnalysisPoint = (args: { + selectedPoint?: GraphPoint; + activeSeries?: SeriesLike; +}): PnlAnalysisPoint | undefined => { + if (!args.selectedPoint || !args.activeSeries) { + return undefined; + } + + return args.activeSeries.pointByTimestamp.get( + args.selectedPoint.date.getTime(), + ); +}; + +export const getLastBalanceHistoryAnalysisPoint = ( + series?: Pick, +): PnlAnalysisPoint | undefined => { + const points = series?.analysisPoints || []; + return points.length ? points[points.length - 1] : undefined; +}; + +export const getDisplayedBalanceHistoryAnalysisPoint = (args: { + selectedPoint?: GraphPoint; + activeSeries?: SeriesLike; + cachedSelectedSeries?: Pick; +}): PnlAnalysisPoint | undefined => { + return ( + getSelectedBalanceHistoryAnalysisPoint({ + selectedPoint: args.selectedPoint, + activeSeries: args.activeSeries, + }) || + getLastBalanceHistoryAnalysisPoint(args.activeSeries) || + getLastBalanceHistoryAnalysisPoint(args.cachedSelectedSeries) + ); +}; + +export const buildBalanceHistoryChartChangeRowData = (args: { + displayedAnalysisPoint?: PnlAnalysisPoint; + quoteCurrency: string; + label?: string; +}): ChangeRowData | undefined => { + if (!args.displayedAnalysisPoint) { + return undefined; + } + + return { + percent: args.displayedAnalysisPoint.totalPnlPercent ?? 0, + deltaFiatFormatted: formatFiatAmount( + args.displayedAnalysisPoint.totalUnrealizedPnlFiat ?? 0, + args.quoteCurrency, + { + customPrecision: 'minimal', + currencyDisplay: 'symbol', + }, + ), + rangeLabel: args.label, + }; +}; + +export const areBalanceHistoryChartChangeRowDataEqual = ( + a?: ChangeRowData, + b?: ChangeRowData, +): boolean => { + return ( + a?.percent === b?.percent && + a?.deltaFiatFormatted === b?.deltaFiatFormatted && + a?.rangeLabel === b?.rangeLabel + ); +}; + +export const getSelectedBalanceHistoryValue = (args: { + point: GraphPoint; + activeSeries?: SeriesLike; + balanceOffset: number; +}): number => { + const analysisPoint = args.activeSeries?.pointByTimestamp.get( + args.point.date.getTime(), + ); + return typeof analysisPoint?.totalFiatBalance === 'number' + ? analysisPoint.totalFiatBalance + args.balanceOffset + : args.point.value; +}; diff --git a/src/components/charts/chartLayout.ts b/src/components/charts/chartLayout.ts new file mode 100644 index 0000000000..2623e0da66 --- /dev/null +++ b/src/components/charts/chartLayout.ts @@ -0,0 +1,39 @@ +const AXIS_LABEL_HORIZONTAL_PADDING = 5; + +export const getChartAxisLabelPointRatio = ( + pointIndex: number, + arrayLength: number, +): number => { + if (arrayLength <= 1) { + return 0.5; + } + + const maxIndex = arrayLength - 1; + const safePointIndex = Math.min(Math.max(pointIndex, 0), maxIndex); + return safePointIndex / maxIndex; +}; + +export const getChartAxisLabelTranslateX = ({ + index, + arrayLength, + chartWidth, + textWidth, +}: { + index: number; + arrayLength: number; + chartWidth: number; + textWidth: number; +}): number => { + const location = + getChartAxisLabelPointRatio(index, arrayLength) * chartWidth - + textWidth / 2; + const maxLocation = Math.max( + AXIS_LABEL_HORIZONTAL_PADDING, + chartWidth - textWidth, + ); + + return Math.min( + Math.max(location, AXIS_LABEL_HORIZONTAL_PADDING), + maxLocation, + ); +}; diff --git a/src/components/charts/fiatTimeframes.ts b/src/components/charts/fiatTimeframes.ts new file mode 100644 index 0000000000..91e383eed2 --- /dev/null +++ b/src/components/charts/fiatTimeframes.ts @@ -0,0 +1,66 @@ +import type {FiatRateInterval} from '../../store/rate/rate.models'; +import { + FIAT_TIMEFRAME_VALUES, + getFiatTimeframeMetadata, + getFiatTimeframeSeriesInterval, +} from '../../utils/fiatTimeframes'; + +export const FIAT_CHART_TIMEFRAME_VALUES: FiatRateInterval[] = + FIAT_TIMEFRAME_VALUES.slice(); + +export const getFiatChartTimeframeOptions = ( + t: (key: string) => string, +): Array<{label: string; value: FiatRateInterval}> => { + return FIAT_CHART_TIMEFRAME_VALUES.map(value => ({ + value, + label: t(getFiatTimeframeMetadata(value).displayLabel), + })); +}; + +export const getSeriesIntervalForFiatTimeframe = ( + timeframe: FiatRateInterval, +): FiatRateInterval => { + return getFiatTimeframeSeriesInterval(timeframe); +}; + +export const getRangeLabelForFiatTimeframe = ( + t: (key: string) => string, + timeframe: FiatRateInterval, +): string => { + return t(getFiatTimeframeMetadata(timeframe).rangeLabel); +}; + +export const formatRangeOrSelectedPointLabel = (args: { + rangeLabel: string; + selectedTimeframe: FiatRateInterval; + selectedDate?: Date; +}): string => { + const {rangeLabel, selectedTimeframe, selectedDate} = args; + if (!selectedDate) { + return rangeLabel; + } + + const date = selectedDate; + if (selectedTimeframe === '1D') { + return date.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + }); + } + + if (selectedTimeframe === '1W' || selectedTimeframe === '1M') { + return date.toLocaleString([], { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + + return date.toLocaleDateString([], { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +}; diff --git a/src/components/charts/sharedValueGuards.ts b/src/components/charts/sharedValueGuards.ts new file mode 100644 index 0000000000..2f454a9bd9 --- /dev/null +++ b/src/components/charts/sharedValueGuards.ts @@ -0,0 +1,22 @@ +import type {SharedValue} from 'react-native-reanimated'; + +export type NumberSharedValue = + | SharedValue + | Readonly>; + +export const isNumberSharedValue = ( + value: unknown, +): value is NumberSharedValue => { + if (value == null || typeof value !== 'object') { + return false; + } + + if (!Object.prototype.hasOwnProperty.call(value, 'value')) { + return false; + } + + const sharedValue = value as {value?: unknown}; + return ( + typeof sharedValue.value === 'number' && Number.isFinite(sharedValue.value) + ); +}; diff --git a/src/components/charts/timeframeSelectorWidth.ts b/src/components/charts/timeframeSelectorWidth.ts new file mode 100644 index 0000000000..ae69d32818 --- /dev/null +++ b/src/components/charts/timeframeSelectorWidth.ts @@ -0,0 +1,4 @@ +export const getTimeframeSelectorWidth = ( + windowWidth: number, + screenGutter: string, +): number => Math.max(windowWidth - Number.parseInt(screenGutter, 10) * 2, 0); diff --git a/src/components/charts/useBalanceHistoryChartComputeQueue.ts b/src/components/charts/useBalanceHistoryChartComputeQueue.ts new file mode 100644 index 0000000000..a7a99c4566 --- /dev/null +++ b/src/components/charts/useBalanceHistoryChartComputeQueue.ts @@ -0,0 +1,260 @@ +import {startTransition, useCallback, useRef} from 'react'; +import type {Dispatch, MutableRefObject, SetStateAction} from 'react'; +import type {FiatRateInterval} from '../../store/rate/rate.models'; +import {upsertBalanceChartScopeTimeframes} from '../../store/portfolio-charts'; +import {isAbortError} from '../../utils/abort'; +import {formatUnknownError} from '../../utils/errors/formatUnknownError'; +import { + scheduleAfterInteractionsAndFrames, + type ScheduledAfterInteractionsHandle, +} from '../../utils/scheduleAfterInteractionsAndFrames'; +import type {CachedBalanceChartTimeframe} from '../../store/portfolio-charts'; +import type { + BalanceHistoryChartOrchestrationAction, + TimeframeComputeDisposition, +} from './balanceHistoryChartOrchestration'; + +type RetryPolicy = 'retry_interrupted_attempts' | 'suppress_after_attempt'; + +export const useBalanceHistoryChartComputeQueue = < + TSeries, + TChangeRowData, +>(args: { + computeGenerationRef: MutableRefObject; + computeSeriesForTimeframe: ( + timeframe: FiatRateInterval, + signal: AbortSignal, + ) => Promise<{ + cacheEntry: CachedBalanceChartTimeframe; + series: TSeries; + }>; + dispatch: Dispatch; + dispatchTimeframeState: Dispatch< + BalanceHistoryChartOrchestrationAction + >; + getComputeDispositionForTimeframe: ( + timeframe: FiatRateInterval, + retryPolicy: RetryPolicy, + ) => TimeframeComputeDisposition; + getTimeframeAttemptRevision: (timeframe: FiatRateInterval) => string; + getTimeframeRevision: ( + timeframe: FiatRateInterval, + historicalRateDeps?: CachedBalanceChartTimeframe['historicalRateDeps'], + ) => string; + selectedTimeframe: FiatRateInterval; + scopeId: string; + sortedWalletIds: string[]; + balanceOffset: number; + setDisplayState: Dispatch< + SetStateAction< + | { + series: TSeries; + timeframe: FiatRateInterval; + } + | undefined + > + >; + trackScheduledHandle: (handle: ScheduledAfterInteractionsHandle) => void; + onComputeError: (context: string, error: unknown) => void; +}) => { + const enqueueComputeRef = useRef([]); + const computingQueueRef = useRef(false); + const selectedTimeframeRef = useRef(args.selectedTimeframe); + + selectedTimeframeRef.current = args.selectedTimeframe; + + const resetComputeQueue = useCallback(() => { + enqueueComputeRef.current = []; + computingQueueRef.current = false; + }, []); + + const retainOnlyQueuedTimeframe = useCallback( + (timeframe: FiatRateInterval) => { + enqueueComputeRef.current = enqueueComputeRef.current.filter( + queuedTimeframe => queuedTimeframe === timeframe, + ); + }, + [], + ); + + const enqueueTimeframeCompute = useCallback( + (timeframe: FiatRateInterval, prioritize = false) => { + const queue = enqueueComputeRef.current; + const existingIndex = queue.indexOf(timeframe); + if (existingIndex >= 0) { + if (prioritize && existingIndex > 0) { + queue.splice(existingIndex, 1); + queue.unshift(timeframe); + } + return; + } + + if (prioritize) { + queue.unshift(timeframe); + } else { + queue.push(timeframe); + } + }, + [], + ); + + const processQueue = useCallback(() => { + if (computingQueueRef.current) { + return; + } + + computingQueueRef.current = true; + + const runNext = () => { + const nextTimeframe = enqueueComputeRef.current.shift(); + if (!nextTimeframe) { + computingQueueRef.current = false; + return; + } + + const generation = args.computeGenerationRef.current; + const attemptRevision = args.getTimeframeAttemptRevision(nextTimeframe); + + args.dispatchTimeframeState({ + type: 'startCompute', + timeframe: nextTimeframe, + attemptRevision, + generation, + }); + + const computeHandle = scheduleAfterInteractionsAndFrames({ + callback: async signal => { + try { + if (args.computeGenerationRef.current !== generation) { + return; + } + + const computed = await args.computeSeriesForTimeframe( + nextTimeframe, + signal, + ); + const timeframeRevision = args.getTimeframeRevision( + nextTimeframe, + computed.cacheEntry.historicalRateDeps, + ); + + if (args.computeGenerationRef.current !== generation) { + return; + } + + startTransition(() => { + args.dispatchTimeframeState({ + type: 'resolveCompute', + timeframe: nextTimeframe, + attemptRevision, + series: computed.series, + seriesRevision: timeframeRevision, + generation, + }); + + if (nextTimeframe === selectedTimeframeRef.current) { + args.setDisplayState(previous => + previous?.series === computed.series && + previous?.timeframe === nextTimeframe + ? previous + : { + series: computed.series, + timeframe: nextTimeframe, + }, + ); + } + }); + + if (args.computeGenerationRef.current === generation) { + args.dispatch( + upsertBalanceChartScopeTimeframes({ + scopeId: args.scopeId, + walletIds: args.sortedWalletIds, + quoteCurrency: computed.cacheEntry.quoteCurrency, + balanceOffset: args.balanceOffset, + timeframes: [computed.cacheEntry], + }), + ); + } + } catch (error: unknown) { + if ( + args.computeGenerationRef.current !== generation || + signal.aborted || + isAbortError(error) + ) { + return; + } + + args.onComputeError(`compute failed for ${nextTimeframe}`, error); + args.dispatchTimeframeState({ + type: 'rejectCompute', + timeframe: nextTimeframe, + attemptRevision, + error: formatUnknownError(error), + generation, + }); + } finally { + if (args.computeGenerationRef.current !== generation) { + computingQueueRef.current = false; + return; + } + + runNext(); + } + }, + }); + args.trackScheduledHandle(computeHandle); + }; + + runNext(); + }, [ + args.balanceOffset, + args.computeGenerationRef, + args.computeSeriesForTimeframe, + args.dispatch, + args.dispatchTimeframeState, + args.getTimeframeAttemptRevision, + args.getTimeframeRevision, + args.onComputeError, + args.scopeId, + args.setDisplayState, + args.sortedWalletIds, + args.trackScheduledHandle, + ]); + + const queueTimeframeCompute = useCallback( + (timeframe: FiatRateInterval, prioritize = false) => { + enqueueTimeframeCompute(timeframe, prioritize); + processQueue(); + }, + [enqueueTimeframeCompute, processQueue], + ); + + const ensureTimeframeComputed = useCallback( + ( + timeframe: FiatRateInterval, + options?: { + prioritize?: boolean; + retryPolicy?: RetryPolicy; + }, + ) => { + const disposition = args.getComputeDispositionForTimeframe( + timeframe, + options?.retryPolicy || 'retry_interrupted_attempts', + ); + if (!disposition.shouldQueue) { + return; + } + + queueTimeframeCompute(timeframe, !!options?.prioritize); + }, + [args, queueTimeframeCompute], + ); + + return { + ensureTimeframeComputed, + queueTimeframeCompute, + retainOnlyQueuedTimeframe, + resetComputeQueue, + }; +}; diff --git a/src/components/charts/useBalanceHistoryChartSelectionState.ts b/src/components/charts/useBalanceHistoryChartSelectionState.ts new file mode 100644 index 0000000000..10dbac5861 --- /dev/null +++ b/src/components/charts/useBalanceHistoryChartSelectionState.ts @@ -0,0 +1,176 @@ +import { + type Dispatch, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, +} from 'react'; +import type {GraphPoint} from 'react-native-graph'; +import type {FiatRateInterval} from '../../store/rate/rate.models'; +import type {HydratedBalanceChartSeries} from '../../utils/portfolio/chartCache'; +import {formatRangeOrSelectedPointLabel} from './fiatTimeframes'; +import haptic from '../haptic-feedback/haptic'; +import type { + BalanceHistoryChartOrchestrationAction, + TimeframeChartStateByTimeframe, +} from './balanceHistoryChartOrchestration'; +import { + areBalanceHistoryChartChangeRowDataEqual, + buildBalanceHistoryChartChangeRowData, + getDisplayedBalanceHistoryAnalysisPoint, + getSelectedBalanceHistoryValue, + type ChangeRowData, +} from './balanceHistoryChartSelection'; + +export const useBalanceHistoryChartSelectionState = (args: { + activeSeries?: HydratedBalanceChartSeries; + cachedSelectedSeries?: HydratedBalanceChartSeries; + selectedTimeframe: FiatRateInterval; + displayedTimeframe: FiatRateInterval; + rangeLabel: string; + quoteCurrency: string; + balanceOffset: number; + timeframeStateByTimeframe: TimeframeChartStateByTimeframe< + HydratedBalanceChartSeries, + ChangeRowData + >; + dispatchTimeframeState: Dispatch< + BalanceHistoryChartOrchestrationAction< + HydratedBalanceChartSeries, + ChangeRowData + > + >; + onSelectedBalanceChangeRef: MutableRefObject< + ((balance?: number) => void) | undefined + >; + onChangeRowData?: (data: ChangeRowData) => void; +}) => { + const [selectedPoint, setSelectedPoint] = useState(); + const gestureStarted = useRef(false); + const lastHapticPointTsRef = useRef(undefined); + + const clearSelection = useCallback(() => { + gestureStarted.current = false; + lastHapticPointTsRef.current = undefined; + setSelectedPoint(undefined); + args.onSelectedBalanceChangeRef.current?.(undefined); + }, [args.onSelectedBalanceChangeRef]); + + const rangeOrSelectedPointLabel = useMemo(() => { + return formatRangeOrSelectedPointLabel({ + rangeLabel: args.rangeLabel, + selectedTimeframe: args.displayedTimeframe, + selectedDate: selectedPoint?.date, + }); + }, [args.displayedTimeframe, args.rangeLabel, selectedPoint?.date]); + + const displayedAnalysisPoint = useMemo(() => { + return getDisplayedBalanceHistoryAnalysisPoint({ + selectedPoint, + activeSeries: args.activeSeries, + cachedSelectedSeries: args.cachedSelectedSeries, + }); + }, [args.activeSeries, args.cachedSelectedSeries, selectedPoint]); + + const resolvedChangeRowData = useMemo(() => { + return buildBalanceHistoryChartChangeRowData({ + displayedAnalysisPoint, + quoteCurrency: args.quoteCurrency, + label: rangeOrSelectedPointLabel, + }); + }, [displayedAnalysisPoint, args.quoteCurrency, rangeOrSelectedPointLabel]); + + useEffect(() => { + if (!resolvedChangeRowData) { + return; + } + + // While a newly selected timeframe is pending we may still be rendering + // the previously displayed series; don't cache that data under the new key. + if (args.displayedTimeframe !== args.selectedTimeframe) { + return; + } + + const existing = + args.timeframeStateByTimeframe[args.selectedTimeframe] + ?.lastResolvedChangeRowData; + if ( + areBalanceHistoryChartChangeRowDataEqual(existing, resolvedChangeRowData) + ) { + return; + } + + args.dispatchTimeframeState({ + type: 'setResolvedChangeRowData', + timeframe: args.selectedTimeframe, + data: resolvedChangeRowData, + }); + }, [ + args.displayedTimeframe, + args.dispatchTimeframeState, + args.selectedTimeframe, + args.timeframeStateByTimeframe, + resolvedChangeRowData, + ]); + + const displayedChangeRowData = + resolvedChangeRowData || + args.timeframeStateByTimeframe[args.selectedTimeframe] + ?.lastResolvedChangeRowData; + + useEffect(() => { + if (!displayedChangeRowData) { + return; + } + + args.onChangeRowData?.({ + percent: displayedChangeRowData.percent, + deltaFiatFormatted: displayedChangeRowData.deltaFiatFormatted, + rangeLabel: displayedChangeRowData.rangeLabel, + }); + }, [displayedChangeRowData, args.onChangeRowData]); + + const onGestureStarted = useCallback(() => { + gestureStarted.current = true; + lastHapticPointTsRef.current = undefined; + }, []); + + const onGestureEnded = useCallback(() => { + haptic('impactLight'); + clearSelection(); + }, [clearSelection]); + + const onPointSelected = useCallback( + (point: GraphPoint) => { + if (!gestureStarted.current || !args.activeSeries) { + return; + } + + setSelectedPoint(point); + const pointTs = point.date.getTime(); + if (lastHapticPointTsRef.current !== pointTs) { + haptic('impactLight'); + lastHapticPointTsRef.current = pointTs; + } + + args.onSelectedBalanceChangeRef.current?.( + getSelectedBalanceHistoryValue({ + point, + activeSeries: args.activeSeries, + balanceOffset: args.balanceOffset, + }), + ); + }, + [args.activeSeries, args.balanceOffset, args.onSelectedBalanceChangeRef], + ); + + return { + displayedChangeRowData, + clearSelection, + onGestureStarted, + onGestureEnded, + onPointSelected, + }; +}; diff --git a/src/components/charts/useScheduledAfterInteractionsRegistry.ts b/src/components/charts/useScheduledAfterInteractionsRegistry.ts new file mode 100644 index 0000000000..64c8062be7 --- /dev/null +++ b/src/components/charts/useScheduledAfterInteractionsRegistry.ts @@ -0,0 +1,45 @@ +import {useCallback, useRef} from 'react'; +import type {ScheduledAfterInteractionsHandle} from '../../utils/scheduleAfterInteractionsAndFrames'; + +export const useScheduledAfterInteractionsRegistry = () => { + const scheduledHandlesRef = useRef>( + new Set(), + ); + + const cancelAllScheduledWork = useCallback(() => { + for (const handle of scheduledHandlesRef.current) { + handle.cancel(); + } + scheduledHandlesRef.current.clear(); + }, []); + + const trackScheduledHandle = useCallback( + (handle: ScheduledAfterInteractionsHandle) => { + scheduledHandlesRef.current.add(handle); + void handle.done.finally(() => { + scheduledHandlesRef.current.delete(handle); + }); + }, + [], + ); + + const removeScheduledHandle = useCallback( + (handle: ScheduledAfterInteractionsHandle | undefined, cancel = false) => { + if (!handle) { + return; + } + + scheduledHandlesRef.current.delete(handle); + if (cancel) { + handle.cancel(); + } + }, + [], + ); + + return { + cancelAllScheduledWork, + trackScheduledHandle, + removeScheduledHandle, + }; +}; diff --git a/src/components/charts/useStableBalanceHistoryChartAxisLabels.tsx b/src/components/charts/useStableBalanceHistoryChartAxisLabels.tsx new file mode 100644 index 0000000000..01036928e9 --- /dev/null +++ b/src/components/charts/useStableBalanceHistoryChartAxisLabels.tsx @@ -0,0 +1,71 @@ +import React, {useCallback, useRef} from 'react'; +import type {HydratedBalanceChartSeries} from '../../utils/portfolio/chartCache'; +import type {NumberSharedValue} from './sharedValueGuards'; +import ChartAxisLabel from './ChartAxisLabel'; +import type {InteractiveLineChartAxisLabelProps} from './InteractiveLineChart'; + +export const useStableBalanceHistoryChartAxisLabels = (args: { + activeSeries?: HydratedBalanceChartSeries; + axisLabelOpacity?: number | NumberSharedValue; + quoteCurrency: string; +}) => { + const activeSeriesRef = useRef(args.activeSeries); + activeSeriesRef.current = args.activeSeries; + + const axisLabelOpacityRef = useRef(args.axisLabelOpacity); + axisLabelOpacityRef.current = args.axisLabelOpacity; + + const quoteCurrencyRef = useRef(args.quoteCurrency); + quoteCurrencyRef.current = args.quoteCurrency; + + const MaxAxisLabel = useCallback( + ({width}: InteractiveLineChartAxisLabelProps) => { + const series = activeSeriesRef.current; + if (!series?.graphPoints.length) { + return null; + } + + return ( + + ); + }, + [], + ); + + const MinAxisLabel = useCallback( + ({width}: InteractiveLineChartAxisLabelProps) => { + const series = activeSeriesRef.current; + if (!series?.graphPoints.length) { + return null; + } + + return ( + + ); + }, + [], + ); + + return { + MaxAxisLabel, + MinAxisLabel, + }; +}; diff --git a/src/constants/currencies.ts b/src/constants/currencies.ts index 4c9d7104b7..127467501d 100644 --- a/src/constants/currencies.ts +++ b/src/constants/currencies.ts @@ -621,6 +621,11 @@ export const BitpaySupportedMaticTokens: {[key in string]: CurrencyOpts} = { blockTime: 0.2, maxMerchantFee: 'urgent', }, + theme: { + coinColor: '#0074D1', + backgroundColor: '#0074D1', + gradientBackgroundColor: '#0074D1', + }, }, '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359_m': { name: 'USDC', diff --git a/src/navigation/tabs/contacts/components/ContactIcon.tsx b/src/navigation/tabs/contacts/components/ContactIcon.tsx index 66c19d0290..d1e46b11e6 100644 --- a/src/navigation/tabs/contacts/components/ContactIcon.tsx +++ b/src/navigation/tabs/contacts/components/ContactIcon.tsx @@ -67,7 +67,9 @@ const ContactIcon: React.FC = ({ tokenAddress && chain && tokenOptionsByAddress[ - addTokenChainSuffix(tokenAddress.toLowerCase(), chain) + // `addTokenChainSuffix` already lowercases non-SVM chains and must + // preserve case-sensitive SVM mint addresses. + addTokenChainSuffix(tokenAddress.trim(), chain) ]; const img = diff --git a/src/navigation/tabs/home/HomeRoot.tsx b/src/navigation/tabs/home/HomeRoot.tsx index 6a2874392c..661da0713c 100644 --- a/src/navigation/tabs/home/HomeRoot.tsx +++ b/src/navigation/tabs/home/HomeRoot.tsx @@ -62,8 +62,10 @@ import {withErrorFallback} from '../TabScreenErrorFallback'; import TabContainer from '../TabContainer'; import ArchaxFooter from '../../../components/archax/archax-footer'; import {NativeStackScreenProps} from '@react-navigation/native-stack'; +import {useStore} from 'react-redux'; import type {NativeStackNavigationProp} from '@react-navigation/native-stack'; import type {RootStackParamList} from '../../../Root'; +import type {RootState} from '../../../store'; import {TabsScreens, TabsStackParamList} from '../TabsStack'; import { BitpaySupportedCoins, @@ -96,6 +98,7 @@ const HomeRoot: React.FC = ({route, navigation}) => { const dispatch = useAppDispatch(); const {currencyAbbreviation} = route.params || {}; const theme = useTheme(); + const reduxStore = useStore(); const [refreshing, setRefreshing] = useState(false); const brazeMarketingCarousel = useAppSelector(selectBrazeMarketingCarousel); const brazeShopWithCrypto = useAppSelector(selectBrazeShopWithCrypto); @@ -324,10 +327,26 @@ const HomeRoot: React.FC = ({route, navigation}) => { dispatch(requestBrazeContentRefresh()), ]); + const refreshedState = reduxStore.getState() as RootState; + const refreshedKeys = refreshedState.WALLET.keys as Record; + const refreshedVisibleWallets = getVisibleWalletsFromKeys( + refreshedKeys, + refreshedState.APP?.homeCarouselConfig, + ); + const refreshedQuoteCurrency = getQuoteCurrency({ + portfolioQuoteCurrency: refreshedState.PORTFOLIO?.quoteCurrency, + defaultAltCurrencyIsoCode: + refreshedState.APP?.defaultAltCurrency?.isoCode, + }).toUpperCase(); + await dispatch( maybePopulatePortfolioForWallets({ - wallets: visibleWallets, - quoteCurrency, + // IMPORTANT: read wallets from the latest Redux state after the + // balance refresh finishes so portfolio snapshots (and thus the + // chart) are repopulated with up-to-date wallet balances and any + // newly created token wallets with funds. + wallets: refreshedVisibleWallets, + quoteCurrency: refreshedQuoteCurrency, }) as any, ); } catch (err) { @@ -430,7 +449,7 @@ const HomeRoot: React.FC = ({route, navigation}) => { }> {/* ////////////////////////////// PORTFOLIO BALANCE */} {showPortfolioValue ? ( - + ) : null} diff --git a/src/navigation/tabs/home/components/AssetRow.tsx b/src/navigation/tabs/home/components/AssetRow.tsx index 8d2d5c61f9..fd0eb6ddc2 100644 --- a/src/navigation/tabs/home/components/AssetRow.tsx +++ b/src/navigation/tabs/home/components/AssetRow.tsx @@ -4,10 +4,7 @@ import {NavigationProp, useNavigation} from '@react-navigation/native'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import styled, {useTheme} from 'styled-components/native'; import type {RootStackParamList} from '../../../../Root'; -import { - FIAT_RATE_SERIES_CACHED_INTERVALS, - hasValidSeriesForCoin, -} from '../../../../store/rate/rate.models'; +import {FIAT_RATE_SERIES_CACHED_INTERVALS} from '../../../../store/rate/rate.models'; import {TouchableOpacity} from '../../../../components/base/TouchableOpacity'; import {CurrencyImage} from '../../../../components/currency-image/CurrencyImage'; import {ActiveOpacity} from '../../../../components/styled/Containers'; @@ -34,8 +31,11 @@ import { AssetRowItem, canNavigateToExchangeRateForAssetRowItem, } from '../../../../utils/portfolio/assets'; -import {normalizeFiatRateSeriesCoin} from '../../../../utils/portfolio/core/pnl/rates'; import {createSupportedCurrencyOptionLookup} from '../../../../utils/portfolio/supportedCurrencyOptionsLookup'; +import { + getHistoricalRateAssetRequestFromItem, + hasHistoricalRateSeriesForAsset, +} from '../hooks/portfolioAssetHistoryRequests'; const supportedCurrencyOptionLookup = createSupportedCurrencyOptionLookup( SupportedCurrencyOptions, @@ -154,22 +154,39 @@ const AssetRow: React.FC = ({ tokenAddress: item.tokenAddress, }); }, [item.chain, item.currencyAbbreviation, item.tokenAddress]); + const historicalRateRequest = useMemo(() => { + return getHistoricalRateAssetRequestFromItem( + { + currencyAbbreviation: item.currencyAbbreviation, + chain: item.chain, + tokenAddress: item.tokenAddress, + }, + defaultAltCurrency?.isoCode || 'USD', + ); + }, [ + defaultAltCurrency?.isoCode, + item.chain, + item.currencyAbbreviation, + item.tokenAddress, + ]); const hasRate = !!item.hasRate; const hasPnl = !!item.hasPnl; const showPnlPlaceholder = !!item.showPnlPlaceholder; const shouldShowRightSide = hasRate || showPnlPlaceholder; const hasHistoricalV4Rates = useMemo(() => { - return hasValidSeriesForCoin({ + if (!historicalRateRequest) { + return false; + } + + return hasHistoricalRateSeriesForAsset({ cache: fiatRateSeriesCache, - fiatCodeUpper: (defaultAltCurrency?.isoCode || 'USD').toUpperCase(), - normalizedCoin: normalizeFiatRateSeriesCoin(item.currencyAbbreviation), + fiatCode: defaultAltCurrency?.isoCode || 'USD', intervals: FIAT_RATE_SERIES_CACHED_INTERVALS, + coin: historicalRateRequest.coin, + chain: historicalRateRequest.chain, + tokenAddress: historicalRateRequest.tokenAddress, }); - }, [ - defaultAltCurrency?.isoCode, - fiatRateSeriesCache, - item.currencyAbbreviation, - ]); + }, [defaultAltCurrency?.isoCode, fiatRateSeriesCache, historicalRateRequest]); const canNavigate = useMemo(() => { return ( hasHistoricalV4Rates && diff --git a/src/navigation/tabs/home/components/CollapseContentButton.tsx b/src/navigation/tabs/home/components/CollapseContentButton.tsx new file mode 100644 index 0000000000..030b567fba --- /dev/null +++ b/src/navigation/tabs/home/components/CollapseContentButton.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import styled, {useTheme} from 'styled-components/native'; +import {TouchableOpacity} from '@components/base/TouchableOpacity'; +import {type AccessibilityState} from 'react-native'; +import * as Svg from 'react-native-svg'; +import { + CharcoalBlack, + NeutralSlate, + Slate30, + SlateDark, + White, +} from '../../../../styles/colors'; + +const CircleButton = styled(TouchableOpacity)<{ + $borderColor: string; + $isActive: boolean; + $activeBackgroundColor: string; +}>` + width: 40px; + height: 40px; + border-radius: 20px; + border-width: 1px; + border-color: ${({$borderColor}) => $borderColor}; + background-color: ${({$isActive, $activeBackgroundColor}) => + $isActive ? $activeBackgroundColor : 'transparent'}; + align-items: center; + justify-content: center; +`; + +const CollapseContentButtonIcon = ({fill}: {fill: string}) => { + return ( + + + + ); +}; + +type Props = { + onPress?: () => void; + onPressIn?: () => void; + onPressOut?: () => void; + isActive?: boolean; + accessibilityLabel?: string; + accessibilityState?: AccessibilityState; +}; + +const CollapseContentButton: React.FC = ({ + onPress, + onPressIn, + onPressOut, + isActive = false, + accessibilityLabel, + accessibilityState, +}) => { + const theme = useTheme(); + const borderColor = theme.dark ? SlateDark : Slate30; + const iconFill = theme.dark ? White : CharcoalBlack; + const activeBackgroundColor = theme.dark ? CharcoalBlack : NeutralSlate; + + return ( + + + + ); +}; + +export default CollapseContentButton; diff --git a/src/navigation/tabs/home/components/HeaderScanButton.tsx b/src/navigation/tabs/home/components/HeaderScanButton.tsx index ab9fe1e10b..e652468379 100644 --- a/src/navigation/tabs/home/components/HeaderScanButton.tsx +++ b/src/navigation/tabs/home/components/HeaderScanButton.tsx @@ -3,13 +3,7 @@ import React from 'react'; import {TouchableOpacity} from '@components/base/TouchableOpacity'; import * as Svg from 'react-native-svg'; import {HeaderButtonContainer} from './Styled'; -import { - Action, - LightBlue, - LinkBlue, - Midnight, - NeutralSlate, -} from '../../../../styles/colors'; +import {Action, LightBlue, LinkBlue, Midnight} from '../../../../styles/colors'; import {useTheme} from 'styled-components/native'; import {useAppDispatch} from '../../../../utils/hooks'; import {Analytics} from '../../../../store/analytics/analytics.effects'; diff --git a/src/navigation/tabs/home/components/LinkingButtons.tsx b/src/navigation/tabs/home/components/LinkingButtons.tsx index 4cfc4f68df..8ac7a7b5b7 100644 --- a/src/navigation/tabs/home/components/LinkingButtons.tsx +++ b/src/navigation/tabs/home/components/LinkingButtons.tsx @@ -14,17 +14,24 @@ import {Analytics} from '../../../../store/analytics/analytics.effects'; import {TouchableOpacity} from '@components/base/TouchableOpacity'; import {ExternalServicesScreens} from '../../../services/ExternalServicesGroup'; -const ButtonsRow = styled.View<{maxWidth?: number}>` - justify-content: space-between; +const MAX_LINKING_BUTTON_ROW_WIDTH = 450; + +const ButtonsRow = styled.View<{ + $maxWidth?: number; + $compactSpacing?: boolean; +}>` + justify-content: ${({$compactSpacing}) => + $compactSpacing ? 'center' : 'space-between'}; flex-direction: row; align-self: center; - width: ${WIDTH - 24}px; - max-width: ${({maxWidth = 340}) => maxWidth}px; + width: ${({$maxWidth = MAX_LINKING_BUTTON_ROW_WIDTH}) => + Math.min(WIDTH - 24, $maxWidth)}px; + max-width: ${({$maxWidth = MAX_LINKING_BUTTON_ROW_WIDTH}) => $maxWidth}px; `; -const ButtonContainer = styled.View` +const ButtonContainer = styled.View<{$compactSpacing?: boolean}>` align-items: center; - margin: 0; + margin: ${({$compactSpacing}) => ($compactSpacing ? '0 30px' : '0')}; `; const ButtonText = styled(BaseText)` @@ -212,19 +219,25 @@ const LinkingButtons = ({buy, sell, receive, send, swap, maxWidth}: Props) => { hide: !!send?.hide, }, ]; + const visibleButtons = buttonsList.filter(({hide}) => !hide); + const compactSpacing = visibleButtons.length <= 3; + return ( - - {buttonsList.map(({key, label, cta, img, hide}: ButtonListProps) => - hide ? null : ( - + + {visibleButtons.map(({key, label, cta, img}: ButtonListProps) => { + const isDisabled = + ['buy', 'sell', 'swap'].includes(key) && + (!appWasInit || !tokensDataLoaded); + + return ( + { Haptic('impactLight'); cta(); @@ -233,8 +246,8 @@ const LinkingButtons = ({buy, sell, receive, send, swap, maxWidth}: Props) => { {titleCasing(label)} - ), - )} + ); + })} ); }; diff --git a/src/navigation/tabs/home/components/PortfolioBalance.tsx b/src/navigation/tabs/home/components/PortfolioBalance.tsx index 647e884acf..967b5c702a 100644 --- a/src/navigation/tabs/home/components/PortfolioBalance.tsx +++ b/src/navigation/tabs/home/components/PortfolioBalance.tsx @@ -1,40 +1,72 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import styled from 'styled-components/native'; import {BaseText, H2} from '../../../../components/styled/Text'; import {SlateDark, White} from '../../../../styles/colors'; import {useSelector} from 'react-redux'; import {RootState} from '../../../../store'; -import { - calculatePercentageDifference, - formatFiatAmount, -} from '../../../../utils/helper-methods'; +import {formatFiatAmount} from '../../../../utils/helper-methods'; +import {shouldUseCompactFiatAmountText} from '../../../../utils/fiatAmountText'; import InfoSvg from './InfoSvg'; -import {ActiveOpacity} from '../../../../components/styled/Containers'; +import { + ActiveOpacity, + ScreenGutter, +} from '../../../../components/styled/Containers'; import {useAppDispatch, useAppSelector} from '../../../../utils/hooks'; import { showBottomNotificationModal, toggleHideAllBalances, } from '../../../../store/app/app.actions'; -import Percentage from '../../../../components/percentage/Percentage'; +import BalanceHistoryChart from '../../../../components/charts/BalanceHistoryChart'; +import ChartChangeRow from '../../../../components/charts/ChartChangeRow'; import {COINBASE_ENV} from '../../../../api/coinbase/coinbase.constants'; import {useTranslation} from 'react-i18next'; import {TouchableOpacity} from '@components/base/TouchableOpacity'; +import {View, type LayoutRectangle} from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + interpolate, + runOnJS, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import {maskIfHidden} from '../../../../utils/hideBalances'; import { - getPercentageDifferenceFromPercentRatio, - getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots, getQuoteCurrency, getVisibleKeysFromKeys, getVisibleWalletsFromKeys, - hasSnapshotsBeforeMsForWallets, - hasSnapshotsForWallets, walletHasNonZeroLiveBalance, } from '../../../../utils/portfolio/assets'; +import {setHomeChartCollapsed} from '../../../../store/portfolio-charts'; +import type {FiatRateInterval} from '../../../../store/rate/rate.models'; import type {Wallet} from '../../../../store/wallet/wallet.models'; +import CollapseContentButton from './CollapseContentButton'; const PortfolioContainer = styled.View` justify-content: center; align-items: center; + width: 100%; +`; + +const PortfolioTopContent = styled.View<{$leftAligned?: boolean}>` + width: 100%; + padding: 0 ${ScreenGutter}; + align-items: ${({$leftAligned}) => ($leftAligned ? 'flex-start' : 'center')}; +`; + +const ChartStage = styled.View` + width: 100%; + position: relative; + overflow: visible; +`; + +const CollapseButtonContainer = styled(Animated.View)` + position: absolute; + right: 12px; + top: 27px; + z-index: 30; `; const PortfolioBalanceHeader = styled(TouchableOpacity)` @@ -50,18 +82,14 @@ const PortfolioBalanceTitle = styled(BaseText)` color: ${({theme: {dark}}) => (dark ? White : SlateDark)}; `; -const PortfolioBalanceText = styled(BaseText)` - font-weight: bold; - font-size: 31px; - line-height: 40px; +const PortfolioBalanceText = styled(BaseText)<{$isCompact?: boolean}>` + font-size: ${({$isCompact}) => ($isCompact ? '28px' : '39px')}; + font-weight: 700; + line-height: ${({$isCompact}) => ($isCompact ? '40px' : '59px')}; color: ${({theme}) => theme.colors.text}; margin: 2px 0; `; -const PercentageWrapper = styled.View` - align-items: center; -`; - const HiddenBalance = styled(H2)` line-height: 50px; margin: 6px 0; @@ -74,13 +102,36 @@ const PortfolioBalance = () => { const keys = useSelector(({WALLET}: RootState) => WALLET.keys); const portfolio = useSelector(({PORTFOLIO}: RootState) => PORTFOLIO); - const {rates, lastDayRates, fiatRateSeriesCache} = useSelector( - ({RATE}: RootState) => RATE, - ); + const {rates, fiatRateSeriesCache} = useSelector(({RATE}: RootState) => RATE); const defaultAltCurrency = useAppSelector(({APP}) => APP.defaultAltCurrency); const hideAllBalances = useAppSelector(({APP}) => APP.hideAllBalances); const homeCarouselConfig = useAppSelector(({APP}) => APP.homeCarouselConfig); + const { + homeChartCollapsed: persistedHomeChartCollapsed, + homeChartRemountNonce, + } = useAppSelector(({PORTFOLIO_CHARTS}) => PORTFOLIO_CHARTS); + + const [selectedChartBalance, setSelectedChartBalance] = useState< + number | undefined + >(); + const [chartChangeRowData, setChartChangeRowData] = useState<{ + percent: number; + deltaFiatFormatted?: string; + rangeLabel?: string; + }>(); + const [isChartCollapsed, setIsChartCollapsed] = useState( + persistedHomeChartCollapsed, + ); + const [isCollapseButtonActive, setIsCollapseButtonActive] = useState(false); + const collapseProgress = useSharedValue(persistedHomeChartCollapsed ? 1 : 0); + const [chartBlockHeight, setChartBlockHeight] = useState(0); + const [chartStageWidth, setChartStageWidth] = useState(0); + const [chartStageY, setChartStageY] = useState(0); + const collapseButtonPressOpacity = useSharedValue(1); + const [collapseButtonLayout, setCollapseButtonLayout] = + useState(); + const selectedChartTimeframeRef = React.useRef('ALL'); const visibleKeys = useMemo( () => getVisibleKeysFromKeys(keys, homeCarouselConfig), @@ -92,29 +143,30 @@ const PortfolioBalance = () => { visibleKeys.reduce((total, key) => total + (key.totalBalance || 0), 0), [visibleKeys], ); + const visibleKeyIdsSig = useMemo(() => { + return visibleKeys + .map(key => String(key?.id || '')) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)) + .join(','); + }, [visibleKeys]); - const visibleLastDayBalance = useMemo( - () => - visibleKeys.reduce( - (total, key) => total + (key.totalBalanceLastDay || 0), - 0, - ), - [visibleKeys], - ); - - const totalBalance: number = visibleCurrentBalance + coinbaseBalance; + const totalBalanceIncludingCoinbase: number = + visibleCurrentBalance + coinbaseBalance; const dispatch = useAppDispatch(); const walletsAcrossKeys: Wallet[] = useMemo(() => { const allWallets = getVisibleWalletsFromKeys(keys, homeCarouselConfig); + const snapshotsMap = portfolio?.snapshotsByWalletId || {}; const byId = new Map(); for (const w of allWallets) { if (!w?.id) { continue; } - if (!walletHasNonZeroLiveBalance(w)) { + const hasSnaps = !!snapshotsMap[w.id]?.length; + if (!walletHasNonZeroLiveBalance(w) && !hasSnaps) { continue; } if (!byId.has(w.id)) { @@ -122,131 +174,213 @@ const PortfolioBalance = () => { } } return Array.from(byId.values()); - }, [homeCarouselConfig, keys]); - - const legacyPercentageDifference = calculatePercentageDifference( - visibleCurrentBalance, - visibleLastDayBalance, - ); - - const hasSnapshots = hasSnapshotsForWallets({ - snapshotsByWalletId: portfolio?.snapshotsByWalletId || {}, - wallets: walletsAcrossKeys, - }); - const isPopulateInProgress = !!portfolio?.populateStatus?.inProgress; - const hasSnapshotsBeforePopulateStarted = useMemo(() => { - const startedAt = portfolio?.populateStatus?.startedAt; - if (!isPopulateInProgress || typeof startedAt !== 'number') { - return true; - } - - return hasSnapshotsBeforeMsForWallets({ - snapshotsByWalletId: portfolio?.snapshotsByWalletId || {}, - wallets: walletsAcrossKeys, - cutoffMs: startedAt, - }); - }, [ - isPopulateInProgress, - portfolio?.populateStatus?.startedAt, - portfolio?.snapshotsByWalletId, - walletsAcrossKeys, - ]); - const quoteCurrency = getQuoteCurrency({ - portfolioQuoteCurrency: portfolio?.quoteCurrency, - defaultAltCurrencyIsoCode: defaultAltCurrency?.isoCode, - }); - - const portfolioPnlPercentageDifference = useMemo(() => { - if (!hasSnapshots) { - return null; - } + }, [homeCarouselConfig, keys, portfolio?.snapshotsByWalletId]); - const pnl = getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots({ - snapshotsByWalletId: portfolio?.snapshotsByWalletId || {}, - wallets: walletsAcrossKeys, - quoteCurrency, - timeframe: '1D', - rates, - lastDayRates, - fiatRateSeriesCache, - }); - - if (!pnl.available) { - return null; - } + const hasChartData = useMemo(() => { + const snapshotsMap = portfolio?.snapshotsByWalletId || {}; + return walletsAcrossKeys.some(w => (snapshotsMap[w.id] || []).length > 0); + }, [portfolio?.snapshotsByWalletId, walletsAcrossKeys]); + const shouldLeftAlignTopSection = hasChartData && !hideAllBalances; + const collapsedScale = 0.26; + const fullChartHeight = chartBlockHeight || 330; - return getPercentageDifferenceFromPercentRatio(pnl.percentRatio); + useEffect(() => { + const nextCollapsed = + shouldLeftAlignTopSection && persistedHomeChartCollapsed; + setIsChartCollapsed(nextCollapsed); + cancelAnimation(collapseProgress); + collapseProgress.value = nextCollapsed ? 1 : 0; }, [ - fiatRateSeriesCache, - hasSnapshots, - lastDayRates, - quoteCurrency, - portfolio?.snapshotsByWalletId, - rates, - walletsAcrossKeys, + collapseProgress, + persistedHomeChartCollapsed, + shouldLeftAlignTopSection, ]); - const [ - committedSnapshotPercentageDifference, - setCommittedSnapshotPercentage, - ] = useState(null); + const buttonAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: + interpolate(collapseProgress.value, [0, 1], [1, 0]) * + collapseButtonPressOpacity.value, + }; + }, []); + + const chartScale = useDerivedValue(() => { + return interpolate(collapseProgress.value, [0, 1], [1, collapsedScale]); + }, [collapsedScale]); + + const chartSpacerAnimatedStyle = useAnimatedStyle(() => { + return { + height: interpolate(collapseProgress.value, [0, 1], [fullChartHeight, 0]), + }; + }, [fullChartHeight]); + + const axisLabelOpacity = useDerivedValue(() => { + return interpolate(collapseProgress.value, [0, 0.08, 1], [1, 0, 0]); + }, []); + + const timeframeSelectorOpacity = useDerivedValue(() => { + return interpolate(collapseProgress.value, [0, 0.28, 1], [1, 0, 0]); + }, []); + + // Fine-tune the final collapsed Y alignment so the mini chart sits perfectly + // next to the large portfolio balance number (without looking slightly low). + // Positive values push the chart DOWN; smaller values move it UP. + const miniChartVerticalNudge = -8; + const fallbackCollapsedTranslateX = 128; + const fallbackCollapsedTranslateY = + -fullChartHeight * 0.72 + miniChartVerticalNudge; + const targetChartRightInset = + collapseButtonLayout && chartStageWidth + ? Math.max( + 0, + chartStageWidth - + (collapseButtonLayout.x + collapseButtonLayout.width), + ) + : 12; + const collapsedTranslateX = + chartStageWidth > 0 + ? chartStageWidth * ((1 - collapsedScale) / 2) - targetChartRightInset + : fallbackCollapsedTranslateX; + const targetChartTopInStage = + collapseButtonLayout && chartStageWidth + ? collapseButtonLayout.y - chartStageY + : undefined; + const collapsedTranslateY = + typeof targetChartTopInStage === 'number' + ? targetChartTopInStage - + fullChartHeight * ((1 - collapsedScale) / 2) + + miniChartVerticalNudge + : fallbackCollapsedTranslateY; + + const chartWrapperAnimatedStyle = useAnimatedStyle(() => { + const progress = collapseProgress.value; + return { + transform: [ + { + translateX: interpolate(progress, [0, 1], [0, collapsedTranslateX]), + }, + { + translateY: interpolate(progress, [0, 1], [0, collapsedTranslateY]), + }, + {scale: chartScale.value}, + ], + }; + }, [collapsedTranslateX, collapsedTranslateY]); + + const persistHomeChartCollapsePreference = useCallback( + (collapsed: boolean) => { + dispatch(setHomeChartCollapsed(collapsed)); + }, + [dispatch], + ); - useEffect(() => { - if (!isPopulateInProgress) { - if (!hasSnapshots) { - setCommittedSnapshotPercentage(null); + const runChartCollapseAnimation = useCallback( + (toCollapsed: boolean) => { + if (!shouldLeftAlignTopSection) { return; } - - if (portfolioPnlPercentageDifference !== null) { - setCommittedSnapshotPercentage(portfolioPnlPercentageDifference); + if (toCollapsed) { + setIsChartCollapsed(true); } - return; - } - - if ( - committedSnapshotPercentageDifference === null && - hasSnapshotsBeforePopulateStarted && - portfolioPnlPercentageDifference !== null - ) { - setCommittedSnapshotPercentage(portfolioPnlPercentageDifference); - } - }, [ - committedSnapshotPercentageDifference, - hasSnapshots, - hasSnapshotsBeforePopulateStarted, - isPopulateInProgress, - portfolioPnlPercentageDifference, - ]); - const percentageDifference = useMemo(() => { - if (!hasSnapshots) { - return legacyPercentageDifference; - } + cancelAnimation(collapseProgress); + collapseProgress.value = withTiming( + toCollapsed ? 1 : 0, + { + duration: 360, + easing: Easing.inOut(Easing.cubic), + }, + finished => { + if (!finished) { + return; + } + runOnJS(persistHomeChartCollapsePreference)(toCollapsed); + if (!toCollapsed) { + runOnJS(setIsChartCollapsed)(false); + } + }, + ); + }, + [ + collapseProgress, + persistHomeChartCollapsePreference, + shouldLeftAlignTopSection, + ], + ); - if (isPopulateInProgress) { - if (!hasSnapshotsBeforePopulateStarted) { - return legacyPercentageDifference; - } + const onCollapseButtonPressIn = useCallback(() => { + setIsCollapseButtonActive(true); + cancelAnimation(collapseButtonPressOpacity); + collapseButtonPressOpacity.value = withTiming(ActiveOpacity, { + duration: 80, + easing: Easing.linear, + }); + }, [collapseButtonPressOpacity]); + + const onCollapseButtonPressOut = useCallback(() => { + setIsCollapseButtonActive(false); + cancelAnimation(collapseButtonPressOpacity); + collapseButtonPressOpacity.value = withTiming(1, { + duration: 120, + easing: Easing.linear, + }); + }, [collapseButtonPressOpacity]); + + const onCollapseChartPress = useCallback(() => { + setIsCollapseButtonActive(false); + runChartCollapseAnimation(true); + }, [runChartCollapseAnimation]); + + const onExpandChartPress = useCallback(() => { + runChartCollapseAnimation(false); + }, [runChartCollapseAnimation]); + + const onSelectedChartTimeframeChange = useCallback( + (timeframe: FiatRateInterval) => { + selectedChartTimeframeRef.current = timeframe; + }, + [], + ); - if (committedSnapshotPercentageDifference !== null) { - return committedSnapshotPercentageDifference; - } - } + const quoteCurrency = getQuoteCurrency({ + portfolioQuoteCurrency: portfolio?.quoteCurrency, + defaultAltCurrencyIsoCode: defaultAltCurrency?.isoCode, + }); + const collapseChartAccessibilityLabel = t('Collapse portfolio chart'); + const expandChartAccessibilityLabel = t('Expand portfolio chart'); + const chartLifecycleKey = useMemo( + () => + `home-portfolio-charts:${quoteCurrency}:${homeChartRemountNonce}:${visibleKeyIdsSig}`, + [homeChartRemountNonce, quoteCurrency, visibleKeyIdsSig], + ); + const hasInitializedChartLifecycleRef = React.useRef(false); - if (portfolioPnlPercentageDifference !== null) { - return portfolioPnlPercentageDifference; + useEffect(() => { + if (!hasInitializedChartLifecycleRef.current) { + hasInitializedChartLifecycleRef.current = true; + return; } - return legacyPercentageDifference; - }, [ - committedSnapshotPercentageDifference, - hasSnapshots, - hasSnapshotsBeforePopulateStarted, - isPopulateInProgress, - legacyPercentageDifference, - portfolioPnlPercentageDifference, - ]); + setSelectedChartBalance(undefined); + setChartChangeRowData(undefined); + }, [chartLifecycleKey]); + + const displayedPortfolioBalance = + typeof selectedChartBalance === 'number' + ? selectedChartBalance + : totalBalanceIncludingCoinbase; + const formattedPortfolioBalance = useMemo(() => { + return formatFiatAmount( + displayedPortfolioBalance, + defaultAltCurrency.isoCode, + { + currencyDisplay: 'symbol', + }, + ); + }, [defaultAltCurrency.isoCode, displayedPortfolioBalance]); + const shouldUseCompactPortfolioBalanceText = useMemo(() => { + return shouldUseCompactFiatAmountText(formattedPortfolioBalance); + }, [formattedPortfolioBalance]); const showPortfolioBalanceInfoModal = () => { dispatch( @@ -270,41 +404,181 @@ const PortfolioBalance = () => { return ( - - {t('Portfolio Balance')} - - - { - dispatch(toggleHideAllBalances()); - }}> - {!hideAllBalances ? ( - <> - - {formatFiatAmount(totalBalance, defaultAltCurrency.isoCode, { - currencyDisplay: 'symbol', - })} - - {percentageDifference || percentageDifference === 0 ? ( - - { + const nextLayout = e.nativeEvent.layout; + setCollapseButtonLayout(prev => + prev && + prev.x === nextLayout.x && + prev.y === nextLayout.y && + prev.width === nextLayout.width && + prev.height === nextLayout.height + ? prev + : nextLayout, + ); + }} + pointerEvents={isChartCollapsed ? 'none' : 'auto'} + accessibilityElementsHidden={isChartCollapsed} + importantForAccessibility={ + isChartCollapsed ? 'no-hide-descendants' : 'yes' + } + style={buttonAnimatedStyle}> + + + ) : null} + + + + {t('Portfolio Balance')} + + + + { + dispatch(toggleHideAllBalances()); + }}> + {!hideAllBalances ? ( + <> + + {formattedPortfolioBalance} + + + ) : ( + + {maskIfHidden(true, totalBalanceIncludingCoinbase)} + + )} + + + + {shouldLeftAlignTopSection ? ( + + ) : null} + + {!hideAllBalances ? ( + hasChartData ? ( + { + const {width, y} = e.nativeEvent.layout; + if (width > 0 && width !== chartStageWidth) { + setChartStageWidth(width); + } + if (y !== chartStageY) { + setChartStageY(y); + } + }}> + + + { + const h = Math.round(e.nativeEvent.layout.height); + if (h > 0 && h !== chartBlockHeight) { + setChartBlockHeight(h); + } + }}> + - - ) : null} - + {isChartCollapsed ? ( + + ) : null} + + + ) : ( - {maskIfHidden(true, totalBalance)} - )} - + + ) + ) : null} ); }; diff --git a/src/navigation/tabs/home/hooks/portfolioAssetHistoryRequests.ts b/src/navigation/tabs/home/hooks/portfolioAssetHistoryRequests.ts new file mode 100644 index 0000000000..baaa42e57c --- /dev/null +++ b/src/navigation/tabs/home/hooks/portfolioAssetHistoryRequests.ts @@ -0,0 +1,271 @@ +import { + getPortfolioWalletSnapshots, + walletHasNonZeroLiveBalance, + type AssetRowItem, +} from '../../../../utils/portfolio/assets'; +import type {BalanceSnapshotsByWalletId} from '../../../../store/portfolio/portfolio.models'; +import type { + CachedFiatRateInterval, + FiatRateSeriesCache, +} from '../../../../store/rate/rate.models'; +import { + getFiatRateSeriesAssetKey, + hasValidSeriesForCoin, +} from '../../../../store/rate/rate.models'; +import {normalizeFiatRateSeriesCoin} from '../../../../utils/portfolio/core/pnl/rates'; +import { + normalizeFiatRateSeriesChain, + normalizeFiatRateSeriesTokenAddress, +} from '../../../../utils/portfolio/core/fiatRateSeries'; +import type {Wallet} from '../../../../store/wallet/wallet.models'; + +export type HistoricalRateAssetRequest = { + requestKey: string; + coin: string; + chain?: string; + tokenAddress?: string; +}; + +export type HistoricalRateAssetIdentityInput = Pick< + AssetRowItem, + 'currencyAbbreviation' | 'chain' | 'tokenAddress' +>; + +const getWalletCurrencyAbbreviationLower = (wallet: Wallet): string => { + return String(wallet?.currencyAbbreviation || '').toLowerCase(); +}; + +const getWalletChainLower = (wallet: Wallet): string => { + return String(wallet?.chain || '').toLowerCase(); +}; + +const getWalletTokenAddress = (wallet: Wallet): string | undefined => { + const tokenAddress = String(wallet?.tokenAddress || '').trim(); + return tokenAddress || undefined; +}; + +const isMainnetWallet = (wallet: Wallet): boolean => { + return String(wallet?.network || '').toLowerCase() === 'livenet'; +}; + +const walletHasStoredSnapshots = ( + wallet: Wallet, + snapshotsByWalletId: BalanceSnapshotsByWalletId, +): boolean => { + const walletId = String(wallet?.id || ''); + if (!walletId) { + return false; + } + + return getPortfolioWalletSnapshots(snapshotsByWalletId, walletId).length > 0; +}; + +const shouldIncludeWalletInHistoricalRateRequests = (args: { + wallet: Wallet; + snapshotsByWalletId: BalanceSnapshotsByWalletId; +}): boolean => { + if (walletHasNonZeroLiveBalance(args.wallet)) { + return true; + } + + return walletHasStoredSnapshots(args.wallet, args.snapshotsByWalletId); +}; + +const normalizeHistoricalRateAssetIdentity = ( + identity: HistoricalRateAssetIdentityInput, +): + | { + currencyAbbreviation: string; + chain?: string; + tokenAddress?: string; + } + | undefined => { + const coin = normalizeFiatRateSeriesCoin(identity.currencyAbbreviation); + if (!coin) { + return undefined; + } + + const rawTokenAddress = + String(identity.tokenAddress || '').trim() || undefined; + const chain = rawTokenAddress + ? normalizeFiatRateSeriesChain(identity.chain) + : undefined; + const tokenAddress = normalizeFiatRateSeriesTokenAddress( + chain, + rawTokenAddress, + ); + + return { + currencyAbbreviation: coin, + ...(chain ? {chain} : {}), + ...(tokenAddress ? {tokenAddress} : {}), + }; +}; + +export const getHistoricalRateAssetRequestKey = (args: { + fiatCode: string; + coin: string; + chain?: string; + tokenAddress?: string; +}): string => { + const assetKey = getFiatRateSeriesAssetKey(args.coin, { + chain: args.chain, + tokenAddress: args.tokenAddress, + }); + if (!assetKey) { + return ''; + } + + return `${(args.fiatCode || 'USD').toUpperCase()}:${assetKey}`; +}; + +export const getHistoricalRateAssetRequestFromItem = ( + item: HistoricalRateAssetIdentityInput, + fiatCode: string, +): HistoricalRateAssetRequest | undefined => { + const normalized = normalizeHistoricalRateAssetIdentity(item); + if (!normalized) { + return undefined; + } + + const requestKey = getHistoricalRateAssetRequestKey({ + fiatCode, + coin: normalized.currencyAbbreviation, + chain: normalized.chain, + tokenAddress: normalized.tokenAddress, + }); + + if (!requestKey) { + return undefined; + } + + return { + requestKey, + coin: normalized.currencyAbbreviation, + chain: normalized.chain, + tokenAddress: normalized.tokenAddress, + }; +}; + +export const getHistoricalRateAssetRequestItemsForVisibleWalletGroups = ( + wallets: Wallet[] | undefined, + snapshotsByWalletId: BalanceSnapshotsByWalletId, +): HistoricalRateAssetIdentityInput[] => { + const walletsByDisplayGroupKey = new Map(); + + for (const wallet of wallets || []) { + if (!isMainnetWallet(wallet)) { + continue; + } + + const groupKey = getWalletCurrencyAbbreviationLower(wallet); + if (!groupKey) { + continue; + } + + const groupedWallets = walletsByDisplayGroupKey.get(groupKey) || []; + groupedWallets.push(wallet); + walletsByDisplayGroupKey.set(groupKey, groupedWallets); + } + + const requestItemsByAssetKey = new Map< + string, + HistoricalRateAssetIdentityInput + >(); + + for (const groupedWallets of walletsByDisplayGroupKey.values()) { + if (!groupedWallets.some(walletHasNonZeroLiveBalance)) { + continue; + } + + for (const wallet of groupedWallets) { + if ( + !shouldIncludeWalletInHistoricalRateRequests({ + wallet, + snapshotsByWalletId, + }) + ) { + continue; + } + + const normalized = normalizeHistoricalRateAssetIdentity({ + currencyAbbreviation: getWalletCurrencyAbbreviationLower(wallet), + chain: getWalletChainLower(wallet), + tokenAddress: getWalletTokenAddress(wallet), + }); + if (!normalized) { + continue; + } + + const assetKey = getFiatRateSeriesAssetKey( + normalized.currencyAbbreviation, + { + chain: normalized.chain, + tokenAddress: normalized.tokenAddress, + }, + ); + if (!assetKey) { + continue; + } + + requestItemsByAssetKey.set(assetKey, normalized); + } + } + + return Array.from(requestItemsByAssetKey.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([, item]) => item); +}; + +export const hasHistoricalRateSeriesForAsset = (args: { + cache: FiatRateSeriesCache | undefined; + fiatCode: string; + intervals: ReadonlyArray; + coin: string; + chain?: string; + tokenAddress?: string; +}): boolean => { + return hasValidSeriesForCoin({ + cache: args.cache, + fiatCodeUpper: (args.fiatCode || 'USD').toUpperCase(), + normalizedCoin: args.coin, + intervals: args.intervals, + chain: args.chain, + tokenAddress: args.tokenAddress, + }); +}; + +export const getMissingHistoricalRateAssetRequests = (args: { + fiatCode: string; + items: HistoricalRateAssetIdentityInput[]; + cache: FiatRateSeriesCache | undefined; + intervals: ReadonlyArray; +}): HistoricalRateAssetRequest[] => { + const requestsByKey = new Map(); + + for (const item of args.items) { + const request = getHistoricalRateAssetRequestFromItem(item, args.fiatCode); + if (!request) { + continue; + } + + if ( + hasHistoricalRateSeriesForAsset({ + cache: args.cache, + fiatCode: args.fiatCode, + intervals: args.intervals, + coin: request.coin, + chain: request.chain, + tokenAddress: request.tokenAddress, + }) + ) { + continue; + } + + requestsByKey.set(request.requestKey, request); + } + + return Array.from(requestsByKey.values()).sort((a, b) => + a.requestKey.localeCompare(b.requestKey), + ); +}; diff --git a/src/navigation/tabs/home/hooks/usePortfolioAssetRows.ts b/src/navigation/tabs/home/hooks/usePortfolioAssetRows.ts index be90040019..b7b6401ad7 100644 --- a/src/navigation/tabs/home/hooks/usePortfolioAssetRows.ts +++ b/src/navigation/tabs/home/hooks/usePortfolioAssetRows.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useRef} from 'react'; +import {useEffect, useMemo, useRef} from 'react'; import {useIsFocused} from '@react-navigation/native'; import {useStore} from 'react-redux'; import {HISTORIC_RATES_CACHE_DURATION} from '../../../../constants/wallet'; @@ -8,10 +8,7 @@ import type { CachedFiatRateInterval, Rates, } from '../../../../store/rate/rate.models'; -import { - FIAT_RATE_SERIES_CACHED_INTERVALS, - hasValidSeriesForCoin, -} from '../../../../store/rate/rate.models'; +import {FIAT_RATE_SERIES_CACHED_INTERVALS} from '../../../../store/rate/rate.models'; import type {RootState} from '../../../../store'; import {fetchFiatRateSeriesAllIntervals} from '../../../../store/wallet/effects'; import type {Key} from '../../../../store/wallet/wallet.models'; @@ -26,8 +23,12 @@ import { getVisibleWalletsFromKeys, isFiatLoadingForWallets, } from '../../../../utils/portfolio/assets'; -import {normalizeFiatRateSeriesCoin} from '../../../../utils/portfolio/core/pnl/rates'; import {useAppDispatch, useAppSelector} from '../../../../utils/hooks'; +import { + getHistoricalRateAssetRequestItemsForVisibleWalletGroups, + getMissingHistoricalRateAssetRequests, + hasHistoricalRateSeriesForAsset, +} from './portfolioAssetHistoryRequests'; type Args = { gainLossMode: GainLossMode; @@ -159,114 +160,75 @@ const usePortfolioAssetRows = ({gainLossMode, keyId}: Args): Result => { }) as any, ); }, [dispatch, isFocused, quoteCurrency, walletIdsSig, wallets]); - - const lastFetchAttemptByQuoteCoinRef = useRef>({}); - const inFlightFetchByQuoteCoinRef = useRef>(new Set()); - const unsupportedQuoteCoinKeysRef = useRef>(new Set()); + const lastFetchAttemptByAssetRequestRef = useRef>({}); + const inFlightFetchByAssetRequestRef = useRef>(new Set()); + const unsupportedAssetRequestKeysRef = useRef>(new Set()); const lastPopulateTriggerAtRef = useRef(0); - const shouldFetchAllIntervalsForCoin = useCallback( - ( - coin: string, - intervals: ReadonlyArray, - ): boolean => { - return !hasValidSeriesForCoin({ - cache: fiatRateSeriesCache, - fiatCodeUpper: (quoteCurrency || 'USD').toUpperCase(), - normalizedCoin: coin, - intervals, - }); - }, - [fiatRateSeriesCache, quoteCurrency], - ); - - const missingHistoricalCoins = useMemo(() => { - const coins = new Set(); - for (const item of visibleItems) { - const coin = normalizeFiatRateSeriesCoin(item.currencyAbbreviation); - if (!coin) { - continue; - } - if (shouldFetchAllIntervalsForCoin(coin, CACHED_INTERVALS)) { - coins.add(coin); - } - } - return Array.from(coins).sort((a, b) => a.localeCompare(b)); - }, [shouldFetchAllIntervalsForCoin, visibleItems]); - - const tokenParamsByCoin = useMemo(() => { - const paramsByCoin: Record< - string, - {chain?: string; tokenAddress?: string} - > = {}; - for (const item of visibleItems) { - const tokenAddress = item.tokenAddress?.trim(); - const rawCoin = (item.currencyAbbreviation || '').toLowerCase(); - const normalizedCoin = normalizeFiatRateSeriesCoin(rawCoin); - // Only attach token params when the coin key itself represents - // the token symbol (e.g. usdc/usdc.e), not aliases like matic->pol. - if (!tokenAddress || rawCoin !== normalizedCoin) { - continue; - } - if (!paramsByCoin[normalizedCoin]) { - paramsByCoin[normalizedCoin] = { - chain: (item.chain || '').toLowerCase(), - tokenAddress, - }; - } - } - return paramsByCoin; - }, [visibleItems]); + const historicalRateRequestItems = useMemo(() => { + return getHistoricalRateAssetRequestItemsForVisibleWalletGroups( + wallets, + snapshotsByWalletId, + ); + }, [snapshotsByWalletId, wallets]); + + const missingHistoricalAssetRequests = useMemo(() => { + return getMissingHistoricalRateAssetRequests({ + fiatCode: quoteCurrency, + items: historicalRateRequestItems, + cache: fiatRateSeriesCache, + intervals: CACHED_INTERVALS, + }); + }, [fiatRateSeriesCache, historicalRateRequestItems, quoteCurrency]); useEffect(() => { - const fiatCode = (quoteCurrency || 'USD').toUpperCase(); - const activeQuoteCoinKeys = new Set( - missingHistoricalCoins.map(coin => `${fiatCode}:${coin}`), + const activeAssetRequestKeys = new Set( + missingHistoricalAssetRequests.map(asset => asset.requestKey), ); - const nextLastFetchAttemptByQuoteCoin: Record = {}; - for (const quoteCoinKey of Object.keys( - lastFetchAttemptByQuoteCoinRef.current, + const nextLastFetchAttemptByAssetRequest: Record = {}; + for (const assetRequestKey of Object.keys( + lastFetchAttemptByAssetRequestRef.current, )) { - if (activeQuoteCoinKeys.has(quoteCoinKey)) { - nextLastFetchAttemptByQuoteCoin[quoteCoinKey] = - lastFetchAttemptByQuoteCoinRef.current[quoteCoinKey]; + if (activeAssetRequestKeys.has(assetRequestKey)) { + nextLastFetchAttemptByAssetRequest[assetRequestKey] = + lastFetchAttemptByAssetRequestRef.current[assetRequestKey]; } } - lastFetchAttemptByQuoteCoinRef.current = nextLastFetchAttemptByQuoteCoin; - }, [missingHistoricalCoins, quoteCurrency]); + lastFetchAttemptByAssetRequestRef.current = + nextLastFetchAttemptByAssetRequest; + }, [missingHistoricalAssetRequests]); useEffect(() => { - const fiatCode = (quoteCurrency || 'USD').toUpperCase(); - const activeQuoteCoinKeys = new Set( - missingHistoricalCoins.map(coin => `${fiatCode}:${coin}`), + const activeAssetRequestKeys = new Set( + missingHistoricalAssetRequests.map(asset => asset.requestKey), ); - const trackedUnsupported = unsupportedQuoteCoinKeysRef.current; - for (const quoteCoinKey of Array.from(trackedUnsupported)) { - if (!activeQuoteCoinKeys.has(quoteCoinKey)) { - trackedUnsupported.delete(quoteCoinKey); + const trackedUnsupported = unsupportedAssetRequestKeysRef.current; + for (const assetRequestKey of Array.from(trackedUnsupported)) { + if (!activeAssetRequestKeys.has(assetRequestKey)) { + trackedUnsupported.delete(assetRequestKey); } } - for (const quoteCoinKey of activeQuoteCoinKeys) { - trackedUnsupported.add(quoteCoinKey); + for (const assetRequestKey of activeAssetRequestKeys) { + trackedUnsupported.add(assetRequestKey); } - }, [missingHistoricalCoins, quoteCurrency]); + }, [missingHistoricalAssetRequests]); useEffect(() => { - const inFlightFetchByQuoteCoin = inFlightFetchByQuoteCoinRef.current; - const unsupportedQuoteCoinKeys = unsupportedQuoteCoinKeysRef.current; + const inFlightFetchByAssetRequest = inFlightFetchByAssetRequestRef.current; + const unsupportedAssetRequestKeys = unsupportedAssetRequestKeysRef.current; return () => { - inFlightFetchByQuoteCoin.clear(); - lastFetchAttemptByQuoteCoinRef.current = {}; - unsupportedQuoteCoinKeys.clear(); + inFlightFetchByAssetRequest.clear(); + lastFetchAttemptByAssetRequestRef.current = {}; + unsupportedAssetRequestKeys.clear(); lastPopulateTriggerAtRef.current = 0; }; }, []); useEffect(() => { - if (!isFocused || !missingHistoricalCoins.length) { + if (!isFocused || !missingHistoricalAssetRequests.length) { return; } @@ -283,55 +245,57 @@ const usePortfolioAssetRows = ({gainLossMode, keyId}: Args): Result => { sweepInFlight = true; try { let hasSupportTransition = false; - for (const coin of missingHistoricalCoins) { + for (const assetRequest of missingHistoricalAssetRequests) { if (cancelled) { return; } - const quoteCoinKey = `${fiatCode}:${coin}`; + const assetRequestKey = assetRequest.requestKey; const wasPreviouslyUnsupported = - unsupportedQuoteCoinKeysRef.current.has(quoteCoinKey); - unsupportedQuoteCoinKeysRef.current.add(quoteCoinKey); + unsupportedAssetRequestKeysRef.current.has(assetRequestKey); + unsupportedAssetRequestKeysRef.current.add(assetRequestKey); - if (inFlightFetchByQuoteCoinRef.current.has(quoteCoinKey)) { + if (inFlightFetchByAssetRequestRef.current.has(assetRequestKey)) { continue; } const lastAttempt = - lastFetchAttemptByQuoteCoinRef.current[quoteCoinKey] || 0; + lastFetchAttemptByAssetRequestRef.current[assetRequestKey] || 0; if (Date.now() - lastAttempt < minRetryMs) { continue; } - inFlightFetchByQuoteCoinRef.current.add(quoteCoinKey); - lastFetchAttemptByQuoteCoinRef.current[quoteCoinKey] = Date.now(); + inFlightFetchByAssetRequestRef.current.add(assetRequestKey); + lastFetchAttemptByAssetRequestRef.current[assetRequestKey] = + Date.now(); try { - const tokenParams = tokenParamsByCoin[coin]; await dispatch( fetchFiatRateSeriesAllIntervals({ fiatCode, - currencyAbbreviation: coin, - chain: tokenParams?.tokenAddress - ? tokenParams.chain + currencyAbbreviation: assetRequest.coin, + chain: assetRequest.tokenAddress + ? assetRequest.chain : undefined, - tokenAddress: tokenParams?.tokenAddress, + tokenAddress: assetRequest.tokenAddress, }), ); } finally { - inFlightFetchByQuoteCoinRef.current.delete(quoteCoinKey); + inFlightFetchByAssetRequestRef.current.delete(assetRequestKey); } const fiatRateSeriesCacheAfterFetch = store.getState().RATE?.fiatRateSeriesCache; - const isSupportedAfterFetch = hasValidSeriesForCoin({ + const isSupportedAfterFetch = hasHistoricalRateSeriesForAsset({ cache: fiatRateSeriesCacheAfterFetch, - fiatCodeUpper: fiatCode, - normalizedCoin: coin, + fiatCode, intervals: CACHED_INTERVALS, + coin: assetRequest.coin, + chain: assetRequest.chain, + tokenAddress: assetRequest.tokenAddress, }); if (isSupportedAfterFetch) { - unsupportedQuoteCoinKeysRef.current.delete(quoteCoinKey); + unsupportedAssetRequestKeysRef.current.delete(assetRequestKey); if (wasPreviouslyUnsupported) { hasSupportTransition = true; } @@ -377,10 +341,9 @@ const usePortfolioAssetRows = ({gainLossMode, keyId}: Args): Result => { }, [ dispatch, isFocused, - missingHistoricalCoins, + missingHistoricalAssetRequests, quoteCurrency, store, - tokenParamsByCoin, wallets, ]); diff --git a/src/navigation/tabs/settings/about/screens/PortfolioDebug.tsx b/src/navigation/tabs/settings/about/screens/PortfolioDebug.tsx index 0c557458d5..561caec5e0 100644 --- a/src/navigation/tabs/settings/about/screens/PortfolioDebug.tsx +++ b/src/navigation/tabs/settings/about/screens/PortfolioDebug.tsx @@ -19,11 +19,17 @@ import { clearPortfolio, populatePortfolio, } from '../../../../../store/portfolio'; +import {clearPortfolioCharts} from '../../../../../store/portfolio-charts'; import {clearRateState} from '../../../../../store/rate/rate.actions'; +import {ShopActions} from '../../../../../store/shop'; import type {BalanceSnapshot} from '../../../../../store/portfolio/portfolio.models'; import type {Wallet} from '../../../../../store/wallet/wallet.models'; import {Network} from '../../../../../constants'; -import type {FiatRatePoint} from '../../../../../store/rate/rate.models'; +import { + parseFiatRateSeriesCacheKey, + type FiatRatePoint, + type FiatRateSeriesCacheEntry, +} from '../../../../../store/rate/rate.models'; type PortfolioDebugScreenProps = NativeStackScreenProps< AboutGroupParamList, @@ -56,7 +62,7 @@ const WalletRowMismatchText = styled(WalletRowSubTitle)` const csvEscape = (v: unknown): string => { const s = v == null ? '' : String(v); - if (/[^\x20-\x7E]|[\n\r,\"]/g.test(s)) { + if (/[^\x20-\x7E]|[\n\r,"]/g.test(s)) { return `"${s.replace(/"/g, '""')}"`; } return s; @@ -68,27 +74,6 @@ type DerivedMismatch = { snapshotUnits: string; }; -const parseFiatRateSeriesCacheKey = ( - cacheKey: string, -): {fiatCode: string; coin: string; interval: string} | undefined => { - if (!cacheKey || typeof cacheKey !== 'string') { - return undefined; - } - const first = cacheKey.indexOf(':'); - if (first <= 0) { - return undefined; - } - const second = cacheKey.indexOf(':', first + 1); - if (second <= first + 1) { - return undefined; - } - return { - fiatCode: cacheKey.slice(0, first).toUpperCase(), - coin: cacheKey.slice(first + 1, second).toLowerCase(), - interval: cacheKey.slice(second + 1), - }; -}; - const getFiniteTsBounds = ( points: FiatRatePoint[] | undefined, ): {startTsMs?: number; endTsMs?: number} => { @@ -158,7 +143,6 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { const {walletNameById, allWallets} = useMemo(() => { const nameMap: {[walletId: string]: string | undefined} = {}; - const walletMap: {[walletId: string]: Wallet | undefined} = {}; const all: Wallet[] = []; for (const key of Object.values(walletKeys || {}) as any[]) { const wallets: Wallet[] = Array.isArray(key?.wallets) ? key.wallets : []; @@ -166,11 +150,10 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { all.push(w); if (w?.id) { nameMap[w.id] = w.walletName; - walletMap[w.id] = w; } } } - return {walletNameById: nameMap, walletById: walletMap, allWallets: all}; + return {walletNameById: nameMap, allWallets: all}; }, [walletKeys]); const {mainnetWallets, testnetWallets, mainnetWalletsWithZeroBalance} = @@ -234,7 +217,8 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { const task = InteractionManager.runAfterInteractions(() => { try { dispatch(clearPortfolio()); - } catch (e) { + dispatch(clearPortfolioCharts()); + } catch { } finally { setIsGenerating(false); } @@ -253,7 +237,7 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { const task = InteractionManager.runAfterInteractions(async () => { try { await dispatch(populatePortfolio()); - } catch (e) { + } catch { } finally { setIsGenerating(false); } @@ -276,6 +260,20 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { return () => task.cancel(); }, [dispatch, isGenerating, portfolio.populateStatus?.inProgress]); + const clearShopStore = useCallback(() => { + if (isGenerating || portfolio.populateStatus?.inProgress) { + return; + } + + const task = InteractionManager.runAfterInteractions(() => { + try { + dispatch(ShopActions.clearShopStore()); + } catch {} + }); + + return () => task.cancel(); + }, [dispatch, isGenerating, portfolio.populateStatus?.inProgress]); + const copySnapshotAuditCsv = useCallback(() => { if (isCopyingAudit) { return; @@ -396,12 +394,15 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { const defaultIntervals = ['1D', '1W', '1M', '3M', '1Y', '5Y', 'ALL']; const discoveredIntervals = new Set(); - const byPair = new Map< + const byAsset = new Map< string, { fiatCode: string; + assetKey: string; coin: string; - byInterval: Map; + chain: string; + tokenAddress: string; + byInterval: Map; } >(); @@ -413,17 +414,27 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { continue; } discoveredIntervals.add(parsed.interval); - const pairKey = `${parsed.fiatCode}:${parsed.coin}`; - let pair = byPair.get(pairKey); - if (!pair) { - pair = { + const assetKey = parsed.assetKey || parsed.coin; + const pairKey = `${parsed.fiatCode}:${assetKey}`; + let asset = byAsset.get(pairKey); + if (!asset) { + asset = { fiatCode: parsed.fiatCode, + assetKey, coin: parsed.coin, - byInterval: new Map(), + chain: parsed.chain || '', + tokenAddress: parsed.tokenAddress || '', + byInterval: new Map< + string, + FiatRateSeriesCacheEntry | undefined + >(), }; - byPair.set(pairKey, pair); + byAsset.set(pairKey, asset); } - pair.byInterval.set(parsed.interval, series); + asset.byInterval.set( + parsed.interval, + series as FiatRateSeriesCacheEntry | undefined, + ); } const intervals = Array.from( @@ -437,7 +448,13 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { return a.localeCompare(b); }); - const headers = ['fiatCode', 'coin']; + const headers = [ + 'fiatCode', + 'assetKey', + 'coin', + 'chain', + 'tokenAddress', + ]; for (const interval of intervals) { headers.push(`${interval}_ratesStored`); headers.push(`${interval}_startTsMs`); @@ -446,16 +463,34 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { headers.push(`${interval}_endIso`); } - const sortedPairs = Array.from(byPair.values()).sort((a, b) => { + const sortedPairs = Array.from(byAsset.values()).sort((a, b) => { const fiatCmp = a.fiatCode.localeCompare(b.fiatCode); if (fiatCmp !== 0) { return fiatCmp; } - return a.coin.localeCompare(b.coin); + const coinCmp = a.coin.localeCompare(b.coin); + if (coinCmp !== 0) { + return coinCmp; + } + const chainCmp = a.chain.localeCompare(b.chain); + if (chainCmp !== 0) { + return chainCmp; + } + const tokenCmp = a.tokenAddress.localeCompare(b.tokenAddress); + if (tokenCmp !== 0) { + return tokenCmp; + } + return a.assetKey.localeCompare(b.assetKey); }); const csvRows = sortedPairs.map(pair => { - const row: Array = [pair.fiatCode, pair.coin]; + const row: Array = [ + pair.fiatCode, + pair.assetKey, + pair.coin, + pair.chain, + pair.tokenAddress, + ]; for (const interval of intervals) { const series = pair.byInterval.get(interval); const points = Array.isArray(series?.points) @@ -527,6 +562,13 @@ const PortfolioDebug = ({navigation}: PortfolioDebugScreenProps) => { + + (isGenerating ? null : clearShopStore())}> + {t('Clear Shop Store')} + + + (isCopyingAudit ? null : copySnapshotAuditCsv())} diff --git a/src/navigation/tabs/settings/about/screens/StorageUsage.tsx b/src/navigation/tabs/settings/about/screens/StorageUsage.tsx index ad640dc45c..46e41acaed 100644 --- a/src/navigation/tabs/settings/about/screens/StorageUsage.tsx +++ b/src/navigation/tabs/settings/about/screens/StorageUsage.tsx @@ -109,7 +109,7 @@ const StorageUsage: React.FC = () => { const [appSize, setAppSize] = useState(''); const [deviceFreeStorage, setDeviceFreeStorage] = useState(''); const [deviceTotalStorage, setDeviceTotalStorage] = useState(''); - const [giftCardtStorage, setGiftCardStorage] = useState(''); + const [giftCardStorage, setGiftCardStorage] = useState(''); const [walletStorage, setWalletStorage] = useState(''); const [customTokenStorage, setCustomTokenStorage] = useState(''); const [contactStorage, setContactStorage] = useState(''); @@ -193,7 +193,7 @@ const StorageUsage: React.FC = () => { const data = parsed?.SHOP_CATALOG; const bytes = data ? JSON.stringify(data).length : 0; setShopCatalogStorage(formatBytes(bytes)); - } catch (_) { + } catch { setShopCatalogStorage('0 Bytes'); } } else { @@ -244,12 +244,12 @@ const StorageUsage: React.FC = () => { }; const _setDataCounterStorage = async () => { try { - const wallets = Object.values(keys).map(keyItem => { - const {wallets} = keyItem as {wallets: Array}; - return wallets.length; + const walletCounts = Object.values(keys).map(keyItem => { + const {wallets: keyWallets} = keyItem as {wallets: Array}; + return keyWallets.length; }); - const walletsCount = wallets.reduce((a, b) => a + b, 0); - setWalletsCount(walletsCount); + const totalWalletsCount = walletCounts.reduce((a, b) => a + b, 0); + setWalletsCount(totalWalletsCount); setGiftCount(giftCards.length); setContactCount(contacts.length); const _customTokenCount = Object.values(customTokens).length; @@ -329,23 +329,43 @@ const StorageUsage: React.FC = () => { const _setPortfolioStorage = async () => { try { - // Persisted size (redux-persist) is stored as the PORTFOLIO string within persist:root. + // Persisted portfolio storage spans both PORTFOLIO and PORTFOLIO_CHARTS within persist:root. // This reflects the *on-disk* representation (including any transform/encryption output). const root = storage.getString('persist:root'); if (root) { try { const parsed = JSON.parse(root); const portfolioPersisted = parsed?.PORTFOLIO; - if (typeof portfolioPersisted === 'string') { - const persistedBytes = await getSize( - RNFS.TemporaryDirectoryPath + '/portfolio-persisted.txt', - portfolioPersisted, + const portfolioChartsPersisted = parsed?.PORTFOLIO_CHARTS; + if ( + typeof portfolioPersisted === 'string' || + typeof portfolioChartsPersisted === 'string' + ) { + const persistedSizes = await Promise.all([ + typeof portfolioPersisted === 'string' + ? getSize( + RNFS.TemporaryDirectoryPath + + '/portfolio-persisted.txt', + portfolioPersisted, + ) + : Promise.resolve(0), + typeof portfolioChartsPersisted === 'string' + ? getSize( + RNFS.TemporaryDirectoryPath + + '/portfolio-charts-persisted.txt', + portfolioChartsPersisted, + ) + : Promise.resolve(0), + ]); + const persistedBytes = persistedSizes.reduce( + (total, size) => total + size, + 0, ); setPortfolioPersistedStorage(formatBytes(persistedBytes)); } else { setPortfolioPersistedStorage('0 Bytes'); } - } catch (_) { + } catch { setPortfolioPersistedStorage('0 Bytes'); } } else { @@ -392,8 +412,9 @@ const StorageUsage: React.FC = () => { ]); useEffect(() => { + const tripleTapState = tripleTapRef.current; return () => { - const timer = tripleTapRef.current.timer; + const timer = tripleTapState.timer; if (timer) { clearTimeout(timer); } @@ -446,7 +467,7 @@ const StorageUsage: React.FC = () => { {t('Gift Cards')} ({giftCount || '0'}) - {renderValue(giftCardtStorage)} + {renderValue(giftCardStorage)}
diff --git a/src/navigation/tabs/settings/components/General.tsx b/src/navigation/tabs/settings/components/General.tsx index 555a200193..030942d7bc 100644 --- a/src/navigation/tabs/settings/components/General.tsx +++ b/src/navigation/tabs/settings/components/General.tsx @@ -17,6 +17,7 @@ import { clearPortfolio, populatePortfolio, } from '../../../../store/portfolio'; +import {clearPortfolioCharts} from '../../../../store/portfolio-charts'; import {pruneFiatRateSeriesCache} from '../../../../store/rate/rate.actions'; import {useTheme} from '@react-navigation/native'; import {NativeStackScreenProps} from '@react-navigation/native-stack'; @@ -127,6 +128,7 @@ const General: React.FC = ({navigation}) => { ); } dispatch(clearPortfolio({populateDisabled: false})); + dispatch(clearPortfolioCharts()); return; } dispatch( diff --git a/src/navigation/wallet/components/MultipleOutputsTx.tsx b/src/navigation/wallet/components/MultipleOutputsTx.tsx index 46e4379725..7dc36ba2ac 100644 --- a/src/navigation/wallet/components/MultipleOutputsTx.tsx +++ b/src/navigation/wallet/components/MultipleOutputsTx.tsx @@ -109,7 +109,9 @@ const MultipleOutputsTx = ({ const foundToken = tokenAddress && tokenOptionsByAddress[ - addTokenChainSuffix(tokenAddress.toLowerCase(), chain) + // `addTokenChainSuffix` already lowercases non-SVM chains and must + // preserve case-sensitive SVM mint addresses. + addTokenChainSuffix(tokenAddress.trim(), chain) ]; const dispatch = useAppDispatch(); diff --git a/src/navigation/wallet/hooks/useExchangeRateChartData.ts b/src/navigation/wallet/hooks/useExchangeRateChartData.ts index e7ef8ebd26..4ab6ade960 100644 --- a/src/navigation/wallet/hooks/useExchangeRateChartData.ts +++ b/src/navigation/wallet/hooks/useExchangeRateChartData.ts @@ -1,57 +1,65 @@ import {useMemo} from 'react'; +import type {GraphPoint} from 'react-native-graph'; import { CachedFiatRateInterval, - DateRanges, FiatRateInterval, FiatRatePoint, FIAT_RATE_SERIES_TARGET_POINTS, } from '../../../store/rate/rate.models'; import {calculatePercentageDifference} from '../../../utils/helper-methods'; +import {getFiatTimeframeMetadata} from '../../../utils/fiatTimeframes'; +import { + normalizeGraphPointsForChart, + recomputeMinMaxFromGraphPoints, +} from '../../../utils/portfolio/chartGraph'; import {downsampleSeries} from '../../../utils/portfolio/rate'; import { ensureSortedByTsAsc, - getMaxRate, lowerBoundByTs, } from '../../../utils/portfolio/timeSeries'; -export interface ChartDisplayDataType { - date: Date; - value: number; +export type ChartDisplayDataType = GraphPoint; + +export interface ChartExtremaPointType { + index: number; + point: ChartDisplayDataType; } export interface ChartDataType { data: ChartDisplayDataType[]; percentChange: number; priceChange: number; - maxIndex?: number; - maxPoint?: ChartDisplayDataType; - minIndex?: number; - minPoint?: ChartDisplayDataType; + renderedMaxPoint?: ChartExtremaPointType; + renderedMinPoint?: ChartExtremaPointType; } export const defaultDisplayData: ChartDataType = { data: [], percentChange: 0, priceChange: 0, - maxIndex: undefined, - maxPoint: undefined, - minIndex: undefined, - minPoint: undefined, + renderedMaxPoint: undefined, + renderedMinPoint: undefined, }; -const MS_PER_DAY = 24 * 60 * 60 * 1000; -export const HISTORIC_TIMEFRAME_WINDOW_MS: Record<'3M' | '1Y' | '5Y', number> = - { - '3M': DateRanges.Quarter * MS_PER_DAY, - '1Y': DateRanges.Year * MS_PER_DAY, - '5Y': DateRanges.FiveYears * MS_PER_DAY, - }; const SPOT_RATE_MATCH_EPSILON = 1e-12; +const buildRenderedExtremaPoint = ( + index: number, + point: ChartDisplayDataType | undefined, +): ChartExtremaPointType | undefined => { + return point ? {index, point} : undefined; +}; -const getFormattedData = ( +type FormatExchangeRateChartDataOptions = { + assumeSortedByTsAsc?: boolean; +}; + +export const formatExchangeRateChartData = ( historicFiatRates: Array<{ts: number; rate: number}>, + options: FormatExchangeRateChartDataOptions = {}, ): ChartDataType => { - const ratesSorted = ensureSortedByTsAsc(historicFiatRates); + const ratesSorted = options.assumeSortedByTsAsc + ? historicFiatRates + : ensureSortedByTsAsc(historicFiatRates); if (!ratesSorted.length) { return defaultDisplayData; } @@ -60,41 +68,24 @@ const getFormattedData = ( strategy: 'lttb', mode: 'per_coin', }); - const scaledData = rates.map(value => ({ - date: new Date(value.ts), - value: value.rate, - })); - - let maxPoint: ChartDisplayDataType | undefined; - let minPoint: ChartDisplayDataType | undefined; - let maxIndex: number | undefined; - let minIndex: number | undefined; - - for (let index = 0; index < scaledData.length; index++) { - const point = scaledData[index]; - if (Number.isNaN(point.value)) { - continue; - } - - if (typeof maxPoint === 'undefined' || point.value > maxPoint.value) { - maxPoint = point; - maxIndex = index; - } - if (typeof minPoint === 'undefined' || point.value < minPoint.value) { - minPoint = point; - minIndex = index; - } - } + const scaledData = normalizeGraphPointsForChart( + rates.map(value => ({ + date: new Date(value.ts), + value: value.rate, + })), + ) as ChartDisplayDataType[]; + const {maxIndex, maxPoint, minIndex, minPoint} = + recomputeMinMaxFromGraphPoints(scaledData); + const renderedMaxPoint = buildRenderedExtremaPoint(maxIndex, maxPoint); + const renderedMinPoint = buildRenderedExtremaPoint(minIndex, minPoint); if (rates.length < 2) { return { data: scaledData, percentChange: 0, priceChange: 0, - maxIndex, - maxPoint, - minIndex, - minPoint, + renderedMaxPoint, + renderedMinPoint, }; } const percentChange = calculatePercentageDifference( @@ -106,10 +97,8 @@ const getFormattedData = ( data: scaledData, percentChange, priceChange: rates[rates.length - 1].rate - rates[0].rate, - maxIndex, - maxPoint, - minIndex, - minPoint, + renderedMaxPoint, + renderedMinPoint, }; }; @@ -123,64 +112,70 @@ type Args = { type Result = { pointsForChartRaw: FiatRatePoint[] | undefined; displayData: ChartDataType | undefined; - selectedTimeframeHighValue: number | undefined; }; -const useExchangeRateChartData = ({ +type PrepareExchangeRateChartPointsArgs = Args & { + nowMs?: number; +}; + +export const prepareExchangeRateChartPoints = ({ selectedSeriesPoints, selectedTimeframe, seriesDataInterval, currentFiatRate, -}: Args): Result => { - const pointsForChartRaw = useMemo(() => { - if (!selectedSeriesPoints) { - return undefined; - } + nowMs, +}: PrepareExchangeRateChartPointsArgs): FiatRatePoint[] | undefined => { + if (!selectedSeriesPoints) { + return undefined; + } - const pointsToDisplay: FiatRatePoint[] = (() => { - if ( - seriesDataInterval === 'ALL' && - selectedTimeframe !== 'ALL' && - (selectedTimeframe === '3M' || - selectedTimeframe === '1Y' || - selectedTimeframe === '5Y') - ) { - const now = Date.now(); - const windowMs = - selectedTimeframe === '3M' - ? HISTORIC_TIMEFRAME_WINDOW_MS['3M'] - : selectedTimeframe === '1Y' - ? HISTORIC_TIMEFRAME_WINDOW_MS['1Y'] - : HISTORIC_TIMEFRAME_WINDOW_MS['5Y']; - const cutoffTs = now - windowMs; - const pointsSortedByTs = ensureSortedByTsAsc(selectedSeriesPoints); - const startIdx = lowerBoundByTs(pointsSortedByTs, cutoffTs); - return pointsSortedByTs.slice(startIdx); - } - return selectedSeriesPoints; - })(); - - if ( - !pointsToDisplay.length || - !currentFiatRate || - !Number.isFinite(currentFiatRate) - ) { - return pointsToDisplay; - } + const pointsSortedByTs = ensureSortedByTsAsc(selectedSeriesPoints); + const {windowMs} = getFiatTimeframeMetadata(selectedTimeframe); + const pointsToDisplay = + seriesDataInterval === 'ALL' && typeof windowMs === 'number' + ? pointsSortedByTs.slice( + lowerBoundByTs( + pointsSortedByTs, + (typeof nowMs === 'number' ? nowMs : Date.now()) - windowMs, + ), + ) + : pointsSortedByTs; + + if (!pointsToDisplay.length) { + return pointsToDisplay; + } + if (!Number.isFinite(currentFiatRate)) { + return pointsToDisplay; + } - const lastIdx = pointsToDisplay.length - 1; - const last = pointsToDisplay[lastIdx]; - if ( - !last || - Math.abs(last.rate - currentFiatRate) <= SPOT_RATE_MATCH_EPSILON - ) { - return pointsToDisplay; - } + const lastIdx = pointsToDisplay.length - 1; + const last = pointsToDisplay[lastIdx]; + if ( + !last || + Math.abs(last.rate - currentFiatRate) <= SPOT_RATE_MATCH_EPSILON + ) { + return pointsToDisplay; + } + + // Never mutate cached series points in Redux; only override in-memory for rendering. + const copy = [...pointsToDisplay]; + copy[lastIdx] = {...last, rate: currentFiatRate}; + return copy; +}; - // Never mutate cached series points in Redux; only override in-memory for rendering. - const copy = [...pointsToDisplay]; - copy[lastIdx] = {...last, rate: currentFiatRate}; - return copy; +const useExchangeRateChartData = ({ + selectedSeriesPoints, + selectedTimeframe, + seriesDataInterval, + currentFiatRate, +}: Args): Result => { + const pointsForChartRaw = useMemo(() => { + return prepareExchangeRateChartPoints({ + selectedSeriesPoints, + selectedTimeframe, + seriesDataInterval, + currentFiatRate, + }); }, [ currentFiatRate, selectedSeriesPoints, @@ -192,17 +187,14 @@ const useExchangeRateChartData = ({ if (typeof pointsForChartRaw === 'undefined') { return undefined; } - return getFormattedData(pointsForChartRaw); - }, [pointsForChartRaw]); - - const selectedTimeframeHighValue = useMemo(() => { - return getMaxRate(pointsForChartRaw); + return formatExchangeRateChartData(pointsForChartRaw, { + assumeSortedByTsAsc: true, + }); }, [pointsForChartRaw]); return { pointsForChartRaw, displayData, - selectedTimeframeHighValue, }; }; diff --git a/src/navigation/wallet/screens/AccountDetails.tsx b/src/navigation/wallet/screens/AccountDetails.tsx index 36a7367da5..8a008c0e80 100644 --- a/src/navigation/wallet/screens/AccountDetails.tsx +++ b/src/navigation/wallet/screens/AccountDetails.tsx @@ -12,7 +12,7 @@ import React, { } from 'react'; import {RootState} from '../../../store'; import {useTranslation} from 'react-i18next'; -import {WalletGroupParamList, WalletScreens} from '../WalletGroup'; +import {WalletGroupParamList} from '../WalletGroup'; import {useAppDispatch, useAppSelector} from '../../../utils/hooks'; import { Wallet, @@ -31,8 +31,11 @@ import { RefreshControl, SectionList, View, + useWindowDimensions, } from 'react-native'; import {TouchableOpacity} from '@components/base/TouchableOpacity'; +import BalanceHistoryChart from '../../../components/charts/BalanceHistoryChart'; +import {getTimeframeSelectorWidth} from '../../../components/charts/timeframeSelectorWidth'; import { Badge, Balance, @@ -51,6 +54,7 @@ import { import { formatCryptoAddress, formatCurrencyAbbreviation, + formatFiatAmount, shouldScale, sleep, fixWalletAddresses, @@ -91,7 +95,6 @@ import { HeaderRightContainer, ProposalBadgeContainer, ScreenGutter, - WIDTH, } from '../../../components/styled/Containers'; import SearchComponent, { SearchableItem, @@ -236,10 +239,13 @@ const Row = styled.View` align-items: flex-end; `; -const WalletListHeader = styled(TouchableOpacity)<{ +const WalletListHeader = styled(TouchableOpacity)` + padding: 10px; +`; + +const WalletListHeaderLabel = styled.View<{ isActive: boolean; }>` - padding: 10px; opacity: ${({isActive}) => (isActive ? 1 : 0.4)}; `; @@ -249,7 +255,7 @@ const CopyToClipboardContainer = styled.View` `; const HeaderContainer = styled.View` - margin: 32px 0 24px; + margin: 18px 0 24px; `; const TransactionSectionHeaderContainer = styled.View` @@ -292,7 +298,7 @@ const Value = styled(BaseText)` `; const BalanceContainer = styled.View` - padding: 0 15px 40px; + padding: 0 15px 22px; flex-direction: column; `; @@ -325,12 +331,52 @@ const CenteredText = styled(BaseText)` margin-left: 4px; `; +type AccountAddressBadgeProps = { + address?: string; +}; + +const AccountAddressBadge = ({address}: AccountAddressBadgeProps) => { + const [copied, setCopied] = useState(false); + + const copyToClipboard = useCallback(() => { + haptic('impactLight'); + if (!copied && address) { + Clipboard.setString(address); + setCopied(true); + } + }, [address, copied]); + + useEffect(() => { + if (!copied) { + return; + } + const timer = setTimeout(() => { + setCopied(false); + }, 3000); + + return () => clearTimeout(timer); + }, [copied]); + + return ( + + {formatCryptoAddress(address)} + + {!copied ? : } + + + ); +}; + const AccountDetails: React.FC = ({route}) => { const navigation = useNavigation(); const dispatch = useAppDispatch(); const {showOngoingProcess, hideOngoingProcess} = useOngoingProcess(); const {tokenOptionsByAddress} = useTokenContext(); const theme = useTheme(); + const {width: windowWidth} = useWindowDimensions(); const {defaultAltCurrency, hideAllBalances, showPortfolioValue} = useAppSelector(({APP}) => APP); const contactList = useAppSelector(({CONTACT}) => CONTACT.list); @@ -338,7 +384,6 @@ const AccountDetails: React.FC = ({route}) => { const {selectedAccountAddress, keyId, isSvmAccount} = route.params; const [refreshing, setRefreshing] = useState(false); const key = useAppSelector(({WALLET}: RootState) => WALLET.keys[keyId]); - const [copied, setCopied] = useState(false); const [searchVal, setSearchVal] = useState(''); const [activeTab, setActiveTab] = useState('wallets'); @@ -351,7 +396,11 @@ const AccountDetails: React.FC = ({route}) => { const selectedChainFilterOption = useAppSelector( ({APP}) => APP.selectedChainFilterOption, ); - const isSmallScreen = WIDTH < 400; + const isSmallScreen = windowWidth < 400; + const timeframeSelectorWidth = getTimeframeSelectorWidth( + windowWidth, + ScreenGutter, + ); const network = useAppSelector(({APP}) => APP.network); const [history, setHistory] = useState([]); const [accountTransactionsHistory, setAccountTransactionsHistory] = useState<{ @@ -376,7 +425,11 @@ const AccountDetails: React.FC = ({route}) => { ); const [showReceiveAddressBottomModal, setShowReceiveAddressBottomModal] = useState(false); - const rates = useAppSelector(({RATE}) => RATE.rates); + const {rates, fiatRateSeriesCache} = useAppSelector(({RATE}) => RATE); + const snapshotsByWalletId = useAppSelector( + ({PORTFOLIO}) => PORTFOLIO.snapshotsByWalletId, + ); + const [selectedBalance, setSelectedBalance] = useState(); const [showKeyOptions, setShowKeyOptions] = useState(false); const [searchResultsHistory, setSearchResultsHistory] = useState( @@ -390,11 +443,15 @@ const AccountDetails: React.FC = ({route}) => { ({COINBASE}) => !!COINBASE.token[COINBASE_ENV], ); - const keyFullWalletObjs = uniqBy( - key.wallets.filter(w => w.receiveAddress === selectedAccountAddress), - wallet => { - return wallet.id; - }, + const keyFullWalletObjs = useMemo( + () => + uniqBy( + key.wallets.filter(w => w.receiveAddress === selectedAccountAddress), + wallet => { + return wallet.id; + }, + ), + [key, selectedAccountAddress], ); let pendingTxps: AccountProposalsProps = {}; keyFullWalletObjs.forEach(x => { @@ -426,7 +483,13 @@ const AccountDetails: React.FC = ({route}) => { const accountItem = memorizedAccountList.find( a => a.receiveAddress === selectedAccountAddress, )!; - const totalBalance = accountItem?.fiatBalanceFormat; + const totalBalance = + typeof selectedBalance === 'number' + ? formatFiatAmount(selectedBalance, defaultAltCurrency.isoCode, { + currencyDisplay: 'symbol', + customPrecision: 'minimal', + }) + : accountItem?.fiatBalanceFormat; const hasMultipleAccounts = memorizedAccountList.length > 1; const accounts = useAppSelector( @@ -1337,26 +1400,11 @@ const AccountDetails: React.FC = ({route}) => { accountAllocationData.rows, ]); - const copyToClipboard = () => { - haptic('impactLight'); - if (!copied) { - Clipboard.setString(accountItem?.receiveAddress); - setCopied(true); - } - }; + const lockedBalanceCurrencyAbbreviation = + accountItem?.wallets?.[1]?.currencyAbbreviation ?? + accountItem?.wallets?.[0]?.currencyAbbreviation; - useEffect(() => { - if (!copied) { - return; - } - const timer = setTimeout(() => { - setCopied(false); - }, 3000); - - return () => clearTimeout(timer); - }, [copied]); - - const renderListHeaderComponent = useCallback(() => { + const listHeaderComponent = useMemo(() => { const isWalletsTab = activeTab === 'wallets'; const isAllocationTab = activeTab === 'allocation'; const isActivityTab = activeTab === 'activity'; @@ -1379,15 +1427,21 @@ const AccountDetails: React.FC = ({route}) => { )} - - {formatCryptoAddress(accountItem?.receiveAddress)} - - {!copied ? : } - - + + {!hideAllBalances ? ( + + } + /> + ) : null} = ({route}) => { - {accountItem?.fiatLockedBalanceFormat}{' '} - {formatCurrencyAbbreviation( - key.wallets[1].currencyAbbreviation, - )} + {accountItem?.fiatLockedBalanceFormat} + {lockedBalanceCurrencyAbbreviation + ? ` ${formatCurrencyAbbreviation( + lockedBalanceCurrencyAbbreviation, + )}` + : ''} @@ -1467,29 +1523,35 @@ const AccountDetails: React.FC = ({route}) => { { setActiveTab('wallets'); }}> -
{t('Wallets')}
+ +
{t('Wallets')}
+
{showPortfolioValue ? ( { setActiveTab('allocation'); }}> -
{t('Allocation')}
+ +
{t('Allocation')}
+
) : null} { setActiveTab('activity'); await sleep(200); debouncedLoadHistory(selectedChainFilterOption); }}> -
{t('Activity')}
+ +
{t('Activity')}
+
{isSvmAccount || (isSmallScreen && showPortfolioValue) ? null : ( @@ -1533,19 +1595,27 @@ const AccountDetails: React.FC = ({route}) => { ); }, [ activeTab, + accountItem?.fiatLockedBalanceFormat, accountItem?.receiveAddress, - copied, + debouncedLoadHistory, + defaultAltCurrency.isoCode, dispatch, + fiatRateSeriesCache, groupedHistory, hideAllBalances, isSmallScreen, isSvmAccount, + keyFullWalletObjs, + lockedBalanceCurrencyAbbreviation, memorizedAssetsByChainList, navigation, - groupedHistory, + rates, + searchResultsAssets, + searchResultsHistory, searchVal, selectedChainFilterOption, showPortfolioValue, + snapshotsByWalletId, t, totalBalance, ]); @@ -1628,6 +1698,7 @@ const AccountDetails: React.FC = ({route}) => { return ( = ({route}) => { onRefresh={onRefresh} /> } - ListHeaderComponent={renderListHeaderComponent} + ListHeaderComponent={listHeaderComponent} ListFooterComponent={ activeTab === 'wallets' ? listFooterComponentAssetsTab diff --git a/src/navigation/wallet/screens/ExchangeRate.tsx b/src/navigation/wallet/screens/ExchangeRate.tsx index 0da27d158c..d397fb8cf1 100644 --- a/src/navigation/wallet/screens/ExchangeRate.tsx +++ b/src/navigation/wallet/screens/ExchangeRate.tsx @@ -13,27 +13,16 @@ import React, { useState, } from 'react'; import {RefreshControl, ScrollView, View} from 'react-native'; -import type {SelectionDotProps} from 'react-native-graph'; -import {GraphPoint, LineGraph} from 'react-native-graph'; -import {Circle, Group} from '@shopify/react-native-skia'; -import Animated, { - runOnJS, - useAnimatedReaction, - useSharedValue, - withSpring, - withTiming, -} from 'react-native-reanimated'; +import type {GraphPoint} from 'react-native-graph'; import {Path, Svg} from 'react-native-svg'; import {useTranslation} from 'react-i18next'; import styled, {useTheme} from 'styled-components/native'; import HeaderBackButton from '../../../components/back/HeaderBackButton'; import {CurrencyImage} from '../../../components/currency-image/CurrencyImage'; -import Percentage from '../../../components/percentage/Percentage'; import { ActiveOpacity, CardContainer, ScreenGutter, - WIDTH, } from '../../../components/styled/Containers'; import { BaseText, @@ -42,22 +31,14 @@ import { HeaderTitle, Link, } from '../../../components/styled/Text'; -import { - BitpaySupportedCoins, - BitpaySupportedTokens, -} from '../../../constants/currencies'; +import {BitpaySupportedCoins} from '../../../constants/currencies'; import {SupportedCurrencyOptions} from '../../../constants/SupportedCurrencyOptions'; import LinkingButtons from '../../tabs/home/components/LinkingButtons'; -import Loader from '../../../components/loader/Loader'; import { - Action, Black, CharcoalBlack, LightBlack, - LightBlue, - LinkBlue, LuckySevens, - Midnight, ProgressBlue, Slate, Slate10, @@ -74,21 +55,20 @@ import { } from '../../../store/wallet/utils/wallet'; import type {RootState} from '../../../store'; import { - addTokenChainSuffix, calculatePercentageDifference, formatCurrencyAbbreviation, formatFiatAmount, getRateByCurrencyName, } from '../../../utils/helper-methods'; +import {shouldUseCompactFiatAmountText} from '../../../utils/fiatAmountText'; +import {getAssetTheme} from '../../../utils/portfolio/assetTheme'; import { findSupportedCurrencyOptionForAsset, getVisibleWalletsFromKeys, walletHasNonZeroLiveBalance, } from '../../../utils/portfolio/assets'; -import { - getFiatRateChangeForTimeframe, - getFiatRateSeriesIntervalForTimeframe, -} from '../../../utils/portfolio/rate'; +import {getFiatRateSeriesIntervalForTimeframe} from '../../../utils/portfolio/rate'; +import {getFiatTimeframeMetadata} from '../../../utils/fiatTimeframes'; import { ensureSortedByTsAsc, getMaxRate, @@ -126,77 +106,23 @@ import {HISTORIC_RATES_CACHE_DURATION} from '../../../constants/wallet'; import useExchangeRateChartData, { type ChartDataType, defaultDisplayData, - HISTORIC_TIMEFRAME_WINDOW_MS, } from '../hooks/useExchangeRateChartData'; import {SwapCryptoScreens} from '../../services/swap-crypto/SwapCryptoGroup'; - -const AxisLabel = ({ - value, - index, - prevIndex, - arrayLength, - currencyAbbreviation, - type, - textColor, -}: { - value: number; - index: number; - prevIndex?: number; - arrayLength: number; - currencyAbbreviation: string; - type: 'min' | 'max'; - textColor?: string; -}): React.ReactElement => { - const defaultAltCurrency = useAppSelector( - ({APP}: RootState) => APP.defaultAltCurrency, - ); - const theme = useTheme(); - const [textWidth, setTextWidth] = useState(50); - const prevLocation = - ((prevIndex ?? index) / arrayLength) * WIDTH - textWidth / 2; - const location = (index / arrayLength) * WIDTH - textWidth / 2; - const getTranslateX = (loc: number) => { - const minLocation = 5; - const maxLocation = WIDTH - textWidth; - return Math.min(Math.max(loc, minLocation), maxLocation); - }; - const prevTranslateX = getTranslateX(prevLocation); - const newTranslateX = getTranslateX(location); - const translateX = useSharedValue(prevTranslateX); - translateX.value = withSpring(newTranslateX, { - mass: 1, - stiffness: 500, - damping: 400, - velocity: 0, - }); - const translateY = type === 'min' ? 5 : -5; - const opacity = useSharedValue(typeof prevIndex !== 'undefined' ? 1 : 0); - opacity.value = withTiming(1, {duration: 800}); - const labelColor = textColor ?? (theme.dark ? Slate30 : SlateDark); - return ( - - setTextWidth(event.nativeEvent.layout.width)}> - - {formatFiatAmount(value, defaultAltCurrency.isoCode, { - currencyAbbreviation, - })} - - - - ); -}; +import {getExchangeRateTimeframeChange} from './ExchangeRate.utils'; + +import ChartAxisLabel from '../../../components/charts/ChartAxisLabel'; +import ChartSelectionDot from '../../../components/charts/ChartSelectionDot'; +import InteractiveLineChart, { + type InteractiveLineChartAxisLabelProps, +} from '../../../components/charts/InteractiveLineChart'; +import TimeframeSelector from '../../../components/charts/TimeframeSelector'; +import ChartChangeRow from '../../../components/charts/ChartChangeRow'; +import { + formatRangeOrSelectedPointLabel, + getFiatChartTimeframeOptions, + getRangeLabelForFiatTimeframe, +} from '../../../components/charts/fiatTimeframes'; +import {IsSVMChain} from '../../../store/wallet/utils/currency'; const formatCompactNumber = (value: number, maximumFractionDigits = 2) => { const abs = Math.abs(value); @@ -306,24 +232,51 @@ const formatSupply = (value: number, maximumFractionDigits = 2) => { return decPart ? `${withCommas}.${decPart}` : withCommas; }; +const ALL_INTERVALS_HIGH_WINDOW_TIMEFRAMES = ['3M', '1Y', '5Y'] as const; + +type CachedIntervalPointMap = Partial< + Record +>; + +const getAllIntervalsHighAcrossCachedWindows = ({ + pointsByInterval, + nowMs = Date.now(), +}: { + pointsByInterval: CachedIntervalPointMap; + nowMs?: number; +}): number | undefined => { + const maxCandidates: number[] = []; + + for (const interval of FIAT_RATE_SERIES_CACHED_INTERVALS) { + const high = getMaxRate(pointsByInterval[interval]); + if (high != null) { + maxCandidates.push(high); + } + } + + const allPoints = pointsByInterval.ALL; + if (allPoints?.length) { + const allPointsSortedByTs = ensureSortedByTsAsc(allPoints); + for (const timeframe of ALL_INTERVALS_HIGH_WINDOW_TIMEFRAMES) { + const {windowMs} = getFiatTimeframeMetadata(timeframe); + if (typeof windowMs !== 'number') { + continue; + } + const startIdx = lowerBoundByTs(allPointsSortedByTs, nowMs - windowMs); + const high = getMaxRateFromIndex(allPointsSortedByTs, startIdx); + if (high != null) { + maxCandidates.push(high); + } + } + } + + return maxCandidates.length ? Math.max(...maxCandidates) : undefined; +}; + const ScreenContainer = styled.SafeAreaView` flex: 1; `; -// const HeaderRight = styled.View` -// flex-direction: row; -// gap: 10px; -// `; - -// const CircleButton = styled(TouchableOpacity)` -// width: 40px; -// height: 40px; -// border-radius: 20px; -// align-items: center; -// justify-content: center; -// background-color: ${({theme}) => (theme.dark ? LightBlack : NeutralSlate)}; -// `; - const HeaderTitleText = styled(HeaderTitle)` font-size: 20px; `; @@ -348,71 +301,6 @@ const PriceText = styled(H2)<{isLargeNumber?: boolean}>` margin-bottom: 5px; `; -const PercentRow = styled.View` - flex-direction: row; - align-items: center; - justify-content: center; -`; - -const ChartContainer = styled.View` - margin-top: 8px; -`; - -const ChartInner = styled.View` - position: relative; - align-items: center; - justify-content: center; - height: 220px; -`; - -const ChartLoaderOverlay = styled.View` - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - justify-content: center; - align-items: center; -`; - -const TimeframeContainer = styled.View` - margin-top: 5px; - padding: 0 0px; -`; - -const TimeframeRow = styled.View` - flex-direction: row; - justify-content: space-between; - align-self: center; - width: ${WIDTH - 24}px; -`; - -const TimeframeHitSlop = {top: 10, bottom: 10, left: 10, right: 10} as const; - -const TimeframePill = styled(TouchableOpacity)<{active: boolean}>` - height: 34px; - min-width: 44px; - padding: 0 12px; - border-radius: 18px; - align-items: center; - justify-content: center; - background-color: ${({theme, active}) => - active ? (theme.dark ? Midnight : LightBlue) : 'transparent'}; -`; - -const TimeframeText = styled(BaseText)<{active: boolean}>` - font-size: 14px; - font-weight: ${({active}) => (active ? 500 : 400)}; - color: ${({theme, active}) => - active - ? theme.dark - ? LinkBlue - : Action - : theme.dark - ? Slate30 - : SlateDark}; -`; - const ActionsContainer = styled.View` margin-top: 20px; margin-bottom: 20px; @@ -562,91 +450,6 @@ const AboutText = styled(BaseText)` color: ${({theme: {dark}}) => (dark ? Slate30 : SlateDark)}; `; -// const RightIconSvg = ({type}: {type: 'star' | 'bell'}) => { -// const theme = useTheme(); -// const fill = theme.dark ? Slate30 : SlateDark; - -// if (type === 'star') { -// return ( -// -// -// -// ); -// } - -// return ( -// -// -// -// ); -// }; - -const ChartSelectionDot = ({ - isActive, - color, - circleX, - circleY, -}: SelectionDotProps): React.ReactElement => { - const outerRadius = useSharedValue(0); - const innerRadius = useSharedValue(0); - - const setIsActive = useCallback( - (active: boolean) => { - outerRadius.value = withSpring(active ? 9 : 0, { - mass: 1, - stiffness: 1000, - damping: 50, - velocity: 0, - }); - innerRadius.value = withSpring(active ? 4 : 0, { - mass: 1, - stiffness: 1000, - damping: 50, - velocity: 0, - }); - }, - [innerRadius, outerRadius], - ); - - useAnimatedReaction( - () => isActive.value, - active => { - runOnJS(setIsActive)(active); - }, - [setIsActive], - ); - - return ( - - - - - ); -}; - -const tokenThemeByCoin: {[key in string]: string} = Object.values( - BitpaySupportedTokens, -).reduce((acc, token) => { - const coinKey = (token.coin || '').toLowerCase(); - const color = token.theme?.coinColor; - if (coinKey && color && !acc[coinKey]) { - acc[coinKey] = color; - } - return acc; -}, {} as {[key in string]: string}); - const ExchangeRate = () => { const {t} = useTranslation(); const theme = useTheme(); @@ -679,12 +482,10 @@ const ExchangeRate = () => { const [displayData, setDisplayData] = useState(defaultDisplayData); - const [prevDisplayData, setPrevDisplayData] = - useState(defaultDisplayData); const displayDataRef = useRef(displayData); - useEffect(() => { - displayDataRef.current = displayData; - }, [displayData]); + // Keep the ref hot; axis label renderers read from it but must not recreate + // their component identities. + displayDataRef.current = displayData; const gestureStarted = useRef(false); const [selectedPoint, setSelectedPoint] = useState< | { @@ -699,6 +500,10 @@ const ExchangeRate = () => { const currencyAbbreviation = formatCurrencyAbbreviation( params?.currencyAbbreviation || 'BTC', ); + const fiatChartTimeframeOptions = useMemo( + () => getFiatChartTimeframeOptions(t), + [t], + ); const coinKey = ( params?.chain || params?.currencyAbbreviation || @@ -706,51 +511,42 @@ const ExchangeRate = () => { ).toLowerCase(); const coin = BitpaySupportedCoins[coinKey] ?? BitpaySupportedCoins.btc; const currencyName = params?.currencyName || coin.name || 'Bitcoin'; - const tokenTheme = useMemo(() => { - const tokenAddress = params?.tokenAddress; - const chain = (params?.chain || '').toLowerCase(); - const abbr = (params?.currencyAbbreviation || '').toLowerCase(); - - if (tokenAddress && chain) { - const tokenKey = addTokenChainSuffix(tokenAddress, chain); - const strictTheme = BitpaySupportedTokens[tokenKey]?.theme; - if (strictTheme) { - return strictTheme; - } - } - - const colorByAbbr = tokenThemeByCoin[abbr]; - if (colorByAbbr) { - return { - coinColor: colorByAbbr, - backgroundColor: colorByAbbr, - gradientBackgroundColor: colorByAbbr, - }; - } + const assetTheme = useMemo( + () => + getAssetTheme({ + currencyAbbreviation: params?.currencyAbbreviation, + chain: params?.chain, + tokenAddress: params?.tokenAddress, + }), + [params?.chain, params?.currencyAbbreviation, params?.tokenAddress], + ); - return undefined; - }, [params?.tokenAddress, params?.chain, params?.currencyAbbreviation]); + const assetContext = useMemo(() => { + const chain = ( + params?.chain || + params?.currencyAbbreviation || + 'btc' + ).toLowerCase(); + const rawTokenAddress = params?.tokenAddress?.trim(); - const assetContext = useMemo( - () => ({ + return { currencyAbbreviation: ( params?.currencyAbbreviation || 'btc' ).toLowerCase(), - chain: ( - params?.chain || - params?.currencyAbbreviation || - 'btc' - ).toLowerCase(), + chain, network: params?.network?.toLowerCase(), - tokenAddress: params?.tokenAddress?.toLowerCase(), - }), - [ - params?.chain, - params?.currencyAbbreviation, - params?.network, - params?.tokenAddress, - ], - ); + tokenAddress: rawTokenAddress + ? IsSVMChain(chain) + ? rawTokenAddress + : rawTokenAddress.toLowerCase() + : undefined, + }; + }, [ + params?.chain, + params?.currencyAbbreviation, + params?.network, + params?.tokenAddress, + ]); const assetCurrencyOption = useMemo( () => @@ -797,13 +593,27 @@ const ExchangeRate = () => { [selectedTimeframe], ); + const historicalRateIdentity = useMemo( + () => ({ + chain: assetContext.tokenAddress ? assetContext.chain : undefined, + tokenAddress: assetContext.tokenAddress || undefined, + }), + [assetContext.chain, assetContext.tokenAddress], + ); + const selectedSeriesKey = useMemo(() => { return getFiatRateSeriesCacheKey( selectedFiatCodeUpper, normalizedCoin, seriesDataInterval, + historicalRateIdentity, ); - }, [normalizedCoin, selectedFiatCodeUpper, seriesDataInterval]); + }, [ + historicalRateIdentity, + normalizedCoin, + selectedFiatCodeUpper, + seriesDataInterval, + ]); const selectedSeries = fiatRateSeriesCache[selectedSeriesKey]; @@ -822,6 +632,7 @@ const ExchangeRate = () => { selectedFiatCodeUpper, normalizedCoin, interval, + historicalRateIdentity, ); const cachedSeries = fiatRateSeriesCacheRef.current[cacheKey]; if (!cachedSeries?.fetchedOn) { @@ -863,6 +674,7 @@ const ExchangeRate = () => { assetContext.currencyAbbreviation, assetContext.tokenAddress, dispatch, + historicalRateIdentity, hasValidNormalizedCoin, normalizedCoin, selectedFiatCodeUpper, @@ -945,23 +757,19 @@ const ExchangeRate = () => { rates, ]); - const { - pointsForChartRaw, - displayData: derivedDisplayData, - selectedTimeframeHighValue, - } = useExchangeRateChartData({ - selectedSeriesPoints: selectedSeries?.points, - selectedTimeframe, - seriesDataInterval, - currentFiatRate, - }); + const {pointsForChartRaw, displayData: derivedDisplayData} = + useExchangeRateChartData({ + selectedSeriesPoints: selectedSeries?.points, + selectedTimeframe, + seriesDataInterval, + currentFiatRate, + }); useEffect(() => { if ( typeof pointsForChartRaw !== 'undefined' && typeof derivedDisplayData !== 'undefined' ) { - setPrevDisplayData(displayDataRef.current); setDisplayData(derivedDisplayData); setIsChartLoading(false); return; @@ -1042,6 +850,7 @@ const ExchangeRate = () => { selectedFiatCodeUpper, normalizedCoin, seriesDataInterval, + historicalRateIdentity, ); const cached = fiatRateSeriesCache[cacheKey]; const isStale = cached @@ -1066,6 +875,8 @@ const ExchangeRate = () => { currencyAbbreviation: assetContext.currencyAbbreviation, interval: seriesDataInterval, spotRate: currentFiatRate, + chain: historicalRateIdentity.chain, + tokenAddress: historicalRateIdentity.tokenAddress, }), ); if (!didAppend) { @@ -1093,55 +904,21 @@ const ExchangeRate = () => { fiatRateSeriesCache, hasWalletsForAsset, hasValidNormalizedCoin, + historicalRateIdentity, normalizedCoin, selectedFiatCodeUpper, seriesDataInterval, ]); const rangeLabel = useMemo(() => { - switch (selectedTimeframe) { - case '1D': - return t('Last Day'); - case '1W': - return t('Past Week'); - case '1M': - return t('Past Month'); - case '3M': - return t('Past 3 Months'); - case '1Y': - return t('Past Year'); - case '5Y': - return t('Past 5 Years'); - case 'ALL': - default: - return t('All-time'); - } + return getRangeLabelForFiatTimeframe(t, selectedTimeframe); }, [selectedTimeframe, t]); const rangeOrSelectedPointLabel = useMemo(() => { - if (!selectedPoint?.date) { - return rangeLabel; - } - const date = selectedPoint.date; - if (selectedTimeframe === '1D') { - return date.toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit', - }); - } - if (selectedTimeframe === '1W' || selectedTimeframe === '1M') { - return date.toLocaleString([], { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); - } - return date.toLocaleDateString([], { - month: 'short', - day: 'numeric', - year: 'numeric', + return formatRangeOrSelectedPointLabel({ + rangeLabel, + selectedTimeframe, + selectedDate: selectedPoint?.date, }); }, [rangeLabel, selectedPoint?.date, selectedTimeframe]); @@ -1183,100 +960,34 @@ const ExchangeRate = () => { return formatDisplayPrice(latestPriceValue ?? fallbackHistoricalPrice); }, [fallbackHistoricalPrice, formatDisplayPrice, latestPriceValue]); - const allIntervalsHighCacheKeys = useMemo( - () => ({ - oneDay: getFiatRateSeriesCacheKey( - selectedFiatCodeUpper, - normalizedCoin, - '1D', - ), - oneWeek: getFiatRateSeriesCacheKey( - selectedFiatCodeUpper, - normalizedCoin, - '1W', - ), - oneMonth: getFiatRateSeriesCacheKey( - selectedFiatCodeUpper, - normalizedCoin, - '1M', - ), - all: getFiatRateSeriesCacheKey( - selectedFiatCodeUpper, - normalizedCoin, - 'ALL', - ), - }), - [normalizedCoin, selectedFiatCodeUpper], - ); - - const oneDaySeriesForAllIntervalsHigh = - fiatRateSeriesCache[allIntervalsHighCacheKeys.oneDay]; - const oneWeekSeriesForAllIntervalsHigh = - fiatRateSeriesCache[allIntervalsHighCacheKeys.oneWeek]; - const oneMonthSeriesForAllIntervalsHigh = - fiatRateSeriesCache[allIntervalsHighCacheKeys.oneMonth]; - const allSeriesForAllIntervalsHigh = - fiatRateSeriesCache[allIntervalsHighCacheKeys.all]; - - const oneDayPointsForAllIntervalsHigh = useMemo( - () => oneDaySeriesForAllIntervalsHigh?.points, - [oneDaySeriesForAllIntervalsHigh?.points], - ); - const oneWeekPointsForAllIntervalsHigh = useMemo( - () => oneWeekSeriesForAllIntervalsHigh?.points, - [oneWeekSeriesForAllIntervalsHigh?.points], - ); - const oneMonthPointsForAllIntervalsHigh = useMemo( - () => oneMonthSeriesForAllIntervalsHigh?.points, - [oneMonthSeriesForAllIntervalsHigh?.points], - ); - const allPointsForAllIntervalsHigh = useMemo( - () => allSeriesForAllIntervalsHigh?.points, - [allSeriesForAllIntervalsHigh?.points], - ); - const allIntervalsHighValue = useMemo(() => { - const maxCandidates: number[] = []; - const cachedIntervalPointSets: Array = [ - oneDayPointsForAllIntervalsHigh, - oneWeekPointsForAllIntervalsHigh, - oneMonthPointsForAllIntervalsHigh, - allPointsForAllIntervalsHigh, - ]; - for (const points of cachedIntervalPointSets) { - const high = getMaxRate(points); - if (high != null) { - maxCandidates.push(high); - } + if (!selectedFiatCodeUpper || !normalizedCoin) { + return undefined; } - if (allPointsForAllIntervalsHigh?.length) { - const now = Date.now(); - const allPointsSortedByTs = ensureSortedByTsAsc( - allPointsForAllIntervalsHigh, + const getPointsForInterval = (interval: CachedFiatRateInterval) => { + const cacheKey = getFiatRateSeriesCacheKey( + selectedFiatCodeUpper, + normalizedCoin, + interval, + historicalRateIdentity, ); - const derivedWindows: Array<{windowMs: number}> = [ - {windowMs: HISTORIC_TIMEFRAME_WINDOW_MS['3M']}, - {windowMs: HISTORIC_TIMEFRAME_WINDOW_MS['1Y']}, - {windowMs: HISTORIC_TIMEFRAME_WINDOW_MS['5Y']}, - ]; - - for (const {windowMs} of derivedWindows) { - const cutoffTs = now - windowMs; - const startIdx = lowerBoundByTs(allPointsSortedByTs, cutoffTs); - const high = getMaxRateFromIndex(allPointsSortedByTs, startIdx); - if (high != null) { - maxCandidates.push(high); - } - } - } + return fiatRateSeriesCache[cacheKey]?.points; + }; - return maxCandidates.length ? Math.max(...maxCandidates) : undefined; + return getAllIntervalsHighAcrossCachedWindows({ + pointsByInterval: { + '1D': getPointsForInterval('1D'), + '1W': getPointsForInterval('1W'), + '1M': getPointsForInterval('1M'), + ALL: getPointsForInterval('ALL'), + }, + }); }, [ - allPointsForAllIntervalsHigh, - oneDayPointsForAllIntervalsHigh, - oneMonthPointsForAllIntervalsHigh, - oneWeekPointsForAllIntervalsHigh, + fiatRateSeriesCache, + historicalRateIdentity, + normalizedCoin, + selectedFiatCodeUpper, ]); const formattedAllIntervalsHighPrice = useMemo(() => { @@ -1294,22 +1005,25 @@ const ExchangeRate = () => { defaultAltCurrency.isoCode, ]); - const timeframeChange = useMemo(() => { - if (!selectedFiatCodeUpper || !normalizedCoin) { - return undefined; - } + const shouldUseCompactTopPriceText = useMemo(() => { + return shouldUseCompactFiatAmountText( + formattedAllIntervalsHighPrice || formattedTopPrice, + ); + }, [formattedAllIntervalsHighPrice, formattedTopPrice]); - return getFiatRateChangeForTimeframe({ + const timeframeChange = useMemo(() => { + return getExchangeRateTimeframeChange({ fiatRateSeriesCache, fiatCode: selectedFiatCodeUpper, - currencyAbbreviation: normalizedCoin, + normalizedCoin, timeframe: selectedTimeframe, currentRate: currentFiatRate, - method: 'linear', + historicalRateIdentity, }); }, [ currentFiatRate, fiatRateSeriesCache, + historicalRateIdentity, normalizedCoin, selectedFiatCodeUpper, selectedTimeframe, @@ -1374,80 +1088,69 @@ const ExchangeRate = () => { timeframeChange, ]); - const chartPoints = useMemo(() => { - return displayData.data; - }, [displayData.data]); + const chartPoints = displayData.data; + + // Axis label renderers are passed to `react-native-graph` as *component + // types*. If we recreate them on every render (e.g. via useCallback deps), + // React treats them as new component types and unmounts/remounts the labels. + // That resets internal measurement/animation state and can show up as a + // jarring "jump" to a clamped edge before sliding to the final position. + // + // Keep stable identities and read the latest values from refs. + const currencyAbbreviationRef = useRef(currencyAbbreviation); + currencyAbbreviationRef.current = currencyAbbreviation; + const quoteCurrencyRef = useRef(defaultAltCurrency.isoCode); + quoteCurrencyRef.current = defaultAltCurrency.isoCode; useEffect(() => { gestureStarted.current = false; setSelectedPoint(undefined); }, [chartPoints]); - const MinAxisLabel = useCallback(() => { - if (isChartLoading) { - return null; - } - if ( - !displayData.data.length || - typeof displayData.minIndex !== 'number' || - displayData.minPoint?.value == null - ) { - return null; - } + const MinAxisLabel = useCallback( + ({width}: InteractiveLineChartAxisLabelProps) => { + const dd = displayDataRef.current; + if (!dd.data.length || dd.renderedMinPoint?.point.value == null) { + return null; + } - return ( - - ); - }, [ - currencyAbbreviation, - displayData.data.length, - displayData.minIndex, - displayData.minPoint?.value, - isChartLoading, - prevDisplayData.minIndex, - ]); + return ( + + ); + }, + [], + ); - const MaxAxisLabel = useCallback(() => { - const maxAxisLabelValue = - selectedTimeframeHighValue ?? displayData.maxPoint?.value; + const MaxAxisLabel = useCallback( + ({width}: InteractiveLineChartAxisLabelProps) => { + const dd = displayDataRef.current; - if (isChartLoading) { - return null; - } - if ( - !displayData.data.length || - typeof displayData.maxIndex !== 'number' || - maxAxisLabelValue == null - ) { - return null; - } + if (!dd.data.length || dd.renderedMaxPoint?.point.value == null) { + return null; + } - return ( - - ); - }, [ - currencyAbbreviation, - displayData.data.length, - displayData.maxIndex, - displayData.maxPoint?.value, - isChartLoading, - prevDisplayData.maxIndex, - selectedTimeframeHighValue, - ]); + return ( + + ); + }, + [], + ); const onPointSelected = useCallback( (p: GraphPoint) => { @@ -1592,7 +1295,7 @@ const ExchangeRate = () => { assetContext.tokenAddress, ]); - const {coinColor, gradientBackgroundColor} = tokenTheme ?? + const {coinColor, gradientBackgroundColor} = assetTheme ?? coin.theme ?? { coinColor: ProgressBlue, gradientBackgroundColor: theme.dark ? 'transparent' : White, @@ -1602,29 +1305,9 @@ const ExchangeRate = () => { navigation.setOptions({ headerTitle: () => {currencyName}, headerLeft: () => , - // headerRight: () => ( - // - // {}}> - // - // - // {}}> - // - // - // - // ), }); }, [currencyName, navigation]); - const timeframes: Array<{label: string; value: FiatRateInterval}> = [ - {label: 'All', value: 'ALL'}, - {label: '1D', value: '1D'}, - {label: '1W', value: '1W'}, - {label: '1M', value: '1M'}, - {label: '3M', value: '3M'}, - {label: '1Y', value: '1Y'}, - {label: '5Y', value: '5Y'}, - ]; - return ( { }> {currencyAbbreviation} - 11 - }> + {formattedTopPrice} - - - + - - - - {isChartLoading ? ( - - - - ) : null} - - - - - - {timeframes.map(({label, value}) => { - const active = selectedTimeframe === value; - return ( - setSelectedTimeframe(value)}> - {label} - - ); - })} - - + + + { + if (!args.fiatCode || !args.normalizedCoin) { + return undefined; + } + + return getFiatRateChangeForTimeframe({ + fiatRateSeriesCache: args.fiatRateSeriesCache, + fiatCode: args.fiatCode, + currencyAbbreviation: args.normalizedCoin, + timeframe: args.timeframe, + currentRate: args.currentRate, + identity: args.historicalRateIdentity, + nowMs: args.nowMs, + method: 'linear', + }); +}; diff --git a/src/navigation/wallet/screens/KeyOverview.tsx b/src/navigation/wallet/screens/KeyOverview.tsx index d50c312eea..7d777b72d3 100644 --- a/src/navigation/wallet/screens/KeyOverview.tsx +++ b/src/navigation/wallet/screens/KeyOverview.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useLayoutEffect, useMemo, + useRef, useState, } from 'react'; import { @@ -14,10 +15,11 @@ import { useTheme, } from '@react-navigation/native'; import {FlashList} from '@shopify/flash-list'; -import {LogBox, RefreshControl, View} from 'react-native'; +import {LogBox, RefreshControl, View, useWindowDimensions} from 'react-native'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import {TouchableOpacity} from 'react-native-gesture-handler'; import styled from 'styled-components/native'; +import {useStore} from 'react-redux'; import haptic from '../../../components/haptic-feedback/haptic'; import { Balance, @@ -125,7 +127,8 @@ import {BitpaySupportedTokenOptsByAddress} from '../../../constants/tokens'; import {BWCErrorMessage} from '../../../constants/BWCError'; import ArchaxFooter from '../../../components/archax/archax-footer'; import {useOngoingProcess, useTokenContext} from '../../../contexts'; -import Percentage from '../../../components/percentage/Percentage'; +import BalanceHistoryChart from '../../../components/charts/BalanceHistoryChart'; +import {getTimeframeSelectorWidth} from '../../../components/charts/timeframeSelectorWidth'; import {getDifferenceColor} from '../../../components/percentage/Percentage'; import Button from '../../../components/button/Button'; import {AllocationDonutLegendCard} from '../../tabs/home/components/AllocationSection'; @@ -186,8 +189,7 @@ const OverviewContainer = styled.SafeAreaView` `; const BalanceContainer = styled.View` - height: 15%; - margin-top: 20px; + margin-top: 8px; padding: 10px 15px; align-items: center; `; @@ -354,14 +356,17 @@ const KeyOverview = () => { } = useRoute>(); const navigation = useNavigation(); const dispatch = useAppDispatch(); + const reduxStore = useStore(); const logger = useLogger(); const theme = useTheme(); const isFocused = useIsFocused(); + const {width: windowWidth} = useWindowDimensions(); const showArchaxBanner = useAppSelector(({APP}) => APP.showArchaxBanner); const {showOngoingProcess, hideOngoingProcess} = useOngoingProcess(); const {tokenOptionsByAddress} = useTokenContext(); const [showKeyOptions, setShowKeyOptions] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [selectedBalance, setSelectedBalance] = useState(); const {keys}: {keys: {[key: string]: Key}} = useAppSelector( ({WALLET}) => WALLET, ); @@ -373,9 +378,17 @@ const KeyOverview = () => { const linkedCoinbase = useAppSelector( ({COINBASE}) => !!COINBASE.token[COINBASE_ENV], ); + const timeframeSelectorWidth = getTimeframeSelectorWidth( + windowWidth, + ScreenGutter, + ); const [showKeyDropdown, setShowKeyDropdown] = useState(false); const key = keys[id]; + + useEffect(() => { + setSelectedBalance(undefined); + }, [id]); const hasMultipleKeys = Object.values(keys).filter(k => k.backupComplete).length > 1; let pendingTxps: any = []; @@ -495,9 +508,13 @@ const KeyOverview = () => { }, [navigation, key?.wallets, context]); useEffect(() => { + if (!key) { + return; + } + dispatch(Analytics.track('View Key')); updateStatusForKey(false); - }, []); + }, [dispatch, key?.id]); const { wallets = [], @@ -550,18 +567,65 @@ const KeyOverview = () => { .join(','); }, [key?.wallets]); - useEffect(() => { - if (!isFocused) { + // If we try to populate portfolio snapshots while another populate pass is + // already running, the thunk may no-op. Track a pending request so we can + // retry once populate finishes, preventing the balance chart from getting + // stuck in a perpetual loading state. + const pendingKeyBalanceChartRefreshRef = useRef(false); + + const maybeRefreshKeyBalanceChart = useCallback(async () => { + const state = reduxStore.getState() as RootState; + if (state.PORTFOLIO?.populateStatus?.inProgress) { + pendingKeyBalanceChartRefreshRef.current = true; return; } - dispatch( + pendingKeyBalanceChartRefreshRef.current = false; + const latestKey = state.WALLET?.keys?.[id] as Key | undefined; + + if (!latestKey?.wallets?.length) { + return; + } + + const latestQuoteCurrency = getQuoteCurrency({ + portfolioQuoteCurrency: state.PORTFOLIO?.quoteCurrency, + defaultAltCurrencyIsoCode: state.APP?.defaultAltCurrency?.isoCode, + }).toUpperCase(); + + await dispatch( maybePopulatePortfolioForWallets({ - wallets: key?.wallets || [], - quoteCurrency, + // IMPORTANT: re-read the latest Redux wallet objects after any + // balance/rate refresh completes so chart snapshot population does not + // get stuck using stale wallet balances from the first render. + wallets: latestKey.wallets, + quoteCurrency: latestQuoteCurrency, }) as any, ); - }, [dispatch, isFocused, keyWalletIdsSig, quoteCurrency]); + }, [dispatch, id, reduxStore]); + + useEffect(() => { + if (!isFocused) { + return; + } + + void maybeRefreshKeyBalanceChart(); + }, [isFocused, keyWalletIdsSig, maybeRefreshKeyBalanceChart, quoteCurrency]); + + useEffect(() => { + if ( + !isFocused || + portfolio.populateStatus?.inProgress || + !pendingKeyBalanceChartRefreshRef.current + ) { + return; + } + + void maybeRefreshKeyBalanceChart(); + }, [ + isFocused, + maybeRefreshKeyBalanceChart, + portfolio.populateStatus?.inProgress, + ]); const isKeyPopulateLoading = useMemo(() => { return isPopulateLoadingForWallets({ @@ -968,14 +1032,18 @@ const KeyOverview = () => { }, }); - const onPressTxpBadge = useMemo( - () => () => { - navigation.navigate('TransactionProposalNotifications', {keyId: key.id}); - }, - [], - ); + const onPressTxpBadge = useCallback(() => { + if (!key?.id) { + return; + } + + navigation.navigate('TransactionProposalNotifications', {keyId: key.id}); + }, [key?.id, navigation]); const updateStatusForKey = async (forceUpdate?: boolean) => { + if (!key) { + return; + } if (isViewUpdating) { logger.debug('KeyOverview is updating. Do not start forced updateAll...'); return; @@ -997,6 +1065,7 @@ const KeyOverview = () => { sleep(1000), ]); dispatch(updatePortfolioBalance()); + await maybeRefreshKeyBalanceChart(); setIsViewUpdating(false); } catch (err) { setIsViewUpdating(false); @@ -1006,8 +1075,11 @@ const KeyOverview = () => { const onRefresh = async () => { setRefreshing(true); - await updateStatusForKey(true); - setRefreshing(false); + try { + await updateStatusForKey(true); + } finally { + setRefreshing(false); + } }; const onPressItem = (item: AccountRowProps) => { @@ -1079,31 +1151,81 @@ const KeyOverview = () => { [key, hideAllBalances], ); - const renderListHeaderComponent = useCallback(() => { + const listHeaderComponent = useMemo(() => { return ( - -
{t('My Wallets')}
- - - searchVal={searchVal} - setSearchVal={setSearchVal} - searchResults={searchResults} - setSearchResults={searchResults => { - setSearchResults(searchResults); - setIsLoadingInitial(false); - }} - searchFullList={memorizedAccountList} - context={'keyoverview'} - /> - -
+ <> + + { + dispatch(toggleHideAllBalances()); + }}> + {!hideAllBalances ? ( + + {formatFiatAmount( + selectedBalance ?? totalBalance, + defaultAltCurrency.isoCode, + { + currencyDisplay: 'symbol', + }, + )} + + ) : ( +

****

+ )} +
+ + {!hideAllBalances ? ( + + ) : null} +
+ + +
{t('My Wallets')}
+ + + searchVal={searchVal} + setSearchVal={setSearchVal} + searchResults={searchResults} + setSearchResults={searchResults => { + setSearchResults(searchResults); + setIsLoadingInitial(false); + }} + searchFullList={memorizedAccountList} + context={'keyoverview'} + /> + +
+ ); - }, [key, hideAllBalances]); + }, [ + defaultAltCurrency.isoCode, + dispatch, + fiatRateSeriesCache, + hideAllBalances, + memorizedAccountList, + portfolio?.snapshotsByWalletId, + quoteCurrency, + rates, + searchResults, + searchVal, + selectedBalance, + t, + totalBalance, + visibleKeyWallets, + ]); const renderListFooterComponent = useCallback(() => { return ( @@ -1258,39 +1380,15 @@ const KeyOverview = () => { return !searchVal && !selectedChainFilterOption ? memorizedAccountList : searchResults; - }, [searchResults, selectedChainFilterOption, key]); + }, [ + memorizedAccountList, + searchResults, + searchVal, + selectedChainFilterOption, + ]); return ( - - { - dispatch(toggleHideAllBalances()); - }}> - {!hideAllBalances ? ( - <> - - {formatFiatAmount(totalBalance, defaultAltCurrency.isoCode, { - currencyDisplay: 'symbol', - })} - - {percentageDifference !== null ? ( - - - - ) : null} - - ) : ( -

****

- )} -
-
- refreshControl={ { onRefresh={() => onRefresh()} /> } - ListHeaderComponent={renderListHeaderComponent} + ListHeaderComponent={listHeaderComponent} ListFooterComponent={renderListFooterComponent} data={renderDataComponent} renderItem={memoizedRenderItem} diff --git a/src/navigation/wallet/screens/TransactionProposalDetails.tsx b/src/navigation/wallet/screens/TransactionProposalDetails.tsx index 5d60cda2ef..30b225025f 100644 --- a/src/navigation/wallet/screens/TransactionProposalDetails.tsx +++ b/src/navigation/wallet/screens/TransactionProposalDetails.tsx @@ -62,10 +62,8 @@ import { } from '../components/ErrorMessages'; import {BWCErrorMessage} from '../../../constants/BWCError'; import {BottomNotificationConfig} from '../../../components/modal/bottom-notification/BottomNotification'; -import { - startUpdateWalletStatus, - waitForTargetAmountAndUpdateWallet, -} from '../../../store/wallet/effects/status/status'; +import {startUpdateWalletStatus} from '../../../store/wallet/effects/status/status'; +import {waitForTargetAmountAndUpdateWallet} from '../../../store/wallet/effects/status/waitForTargetAmountAndUpdateWallet'; import {useTranslation} from 'react-i18next'; import {findWalletById} from '../../../store/wallet/utils/wallet'; import { diff --git a/src/navigation/wallet/screens/WalletDetails.tsx b/src/navigation/wallet/screens/WalletDetails.tsx index 10092ebf32..52d24b36d4 100644 --- a/src/navigation/wallet/screens/WalletDetails.tsx +++ b/src/navigation/wallet/screens/WalletDetails.tsx @@ -20,9 +20,13 @@ import { Share, Text, View, + useWindowDimensions, } from 'react-native'; +import {useStore} from 'react-redux'; import {TouchableOpacity} from '@components/base/TouchableOpacity'; import styled from 'styled-components/native'; +import BalanceHistoryChart from '../../../components/charts/BalanceHistoryChart'; +import {getTimeframeSelectorWidth} from '../../../components/charts/timeframeSelectorWidth'; import Settings from '../../../components/settings/Settings'; import { Balance, @@ -47,6 +51,7 @@ import { isSegwit, isTaproot, } from '../../../store/wallet/utils/wallet'; +import {formatFiatAmount} from '../../../utils/helper-methods'; import { setWalletScanning, updatePortfolioBalance, @@ -89,6 +94,7 @@ import Icons from '../components/WalletIcons'; import {WalletScreens, WalletGroupParamList} from '../WalletGroup'; import {useAppDispatch, useAppSelector} from '../../../utils/hooks'; import {startGetRates} from '../../../store/wallet/effects'; +import {maybePopulatePortfolioForWallets} from '../../../store/portfolio'; import {createWalletAddress} from '../../../store/wallet/effects/address/address'; import { BuildUiFriendlyList, @@ -129,6 +135,7 @@ import { SUPPORTED_VM_TOKENS, } from '../../../constants/currencies'; import ContactIcon from '../../tabs/contacts/components/ContactIcon'; +import {getAssetTheme} from '../../../utils/portfolio/assetTheme'; import { TransactionIcons, TRANSACTION_ICON_SIZE, @@ -140,8 +147,10 @@ import {BillPayAccount} from '../../../store/shop/shop.models'; import debounce from 'lodash.debounce'; import ArchaxFooter from '../../../components/archax/archax-footer'; import {ExternalServicesScreens} from '../../services/ExternalServicesGroup'; -import {isTSSWallet} from '../../../store/wallet/effects/tss-send/tss-send'; +import {isTSSKey} from '../../../store/wallet/effects/tss-send/tss-send'; import {logManager} from '../../../managers/LogManager'; +import type {RootState} from '../../../store'; +import {getQuoteCurrency} from '../../../utils/portfolio/assets'; export type WalletDetailsScreenParamList = { walletId: string; @@ -160,7 +169,7 @@ const WalletDetailsContainer = styled.SafeAreaView` `; const HeaderContainer = styled.View` - margin: 32px 0 24px; + margin: 18px 0 24px; `; const Row = styled.View` @@ -169,6 +178,10 @@ const Row = styled.View` align-items: flex-end; `; +const CryptoBalanceRow = styled(Row)` + margin-top: -5px; +`; + const TouchableRow = styled(TouchableOpacity)` flex-direction: row; justify-content: center; @@ -177,7 +190,7 @@ const TouchableRow = styled(TouchableOpacity)` `; const BalanceContainer = styled.View` - padding: 0 15px 40px; + padding: 0 15px 22px; flex-direction: column; `; @@ -259,6 +272,15 @@ const TypeContainer = styled(HeaderSubTitleContainer)` margin: 10px 4px 0; `; +const NetworkBadgeRow = styled(Row)` + align-items: center; + margin-top: 10px; +`; + +const NetworkBadgeContainer = styled(TypeContainer)` + margin: 0 4px 0 0; +`; + const IconContainer = styled.View` margin-right: 5px; `; @@ -268,6 +290,10 @@ const TypeText = styled(BaseText)` color: ${({theme: {dark}}) => (dark ? LuckySevens : SlateDark)}; `; +const CryptoBalanceText = styled(Paragraph)` + font-size: 13px; +`; + const LinkText = styled(Link)` font-weight: 500; font-size: 18px; @@ -306,18 +332,30 @@ const getWalletType = ( const WalletDetails: React.FC = ({route}) => { const navigation = useNavigation(); const dispatch = useAppDispatch(); + const reduxStore = useStore(); const theme = useTheme(); + const {width: windowWidth} = useWindowDimensions(); const {t} = useTranslation(); const [showWalletOptions, setShowWalletOptions] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [selectedFiatBalance, setSelectedFiatBalance] = useState< + number | undefined + >(); const {walletId, skipInitializeHistory, copayerId} = route.params; const {keys} = useAppSelector(({WALLET}) => WALLET); - const {rates} = useAppSelector(({RATE}) => RATE); + const {rates, fiatRateSeriesCache} = useAppSelector(({RATE}) => RATE); + const snapshotsByWalletId = useAppSelector( + ({PORTFOLIO}) => PORTFOLIO.snapshotsByWalletId, + ); const supportedCardMap = useAppSelector( ({SHOP_CATALOG}) => SHOP_CATALOG.supportedCardMap, ); const locationData = useAppSelector(({LOCATION}) => LOCATION.locationData); + const timeframeSelectorWidth = getTimeframeSelectorWidth( + windowWidth, + ScreenGutter, + ); const wallets = Object.values(keys).flatMap(k => k.wallets); @@ -341,6 +379,41 @@ const WalletDetails: React.FC = ({route}) => { const walletType = getWalletType(key, fullWalletObj); const showArchaxBanner = useAppSelector(({APP}) => APP.showArchaxBanner); + const getLatestWalletFromReduxState = useCallback(() => { + const state = reduxStore.getState() as RootState; + const latestKeys = state.WALLET.keys as Record; + const latestWallets = (Object.values(latestKeys) as Key[]).flatMap( + (walletKey: Key) => walletKey.wallets || [], + ); + const latestWallet = findWalletById(latestWallets, walletId, copayerId) as + | Wallet + | undefined; + + return { + state, + wallet: latestWallet, + }; + }, [copayerId, reduxStore, walletId]); + + const maybeRefreshWalletBalanceChart = useCallback(async () => { + const {state, wallet} = getLatestWalletFromReduxState(); + if (!wallet) { + return; + } + + const quoteCurrency = getQuoteCurrency({ + portfolioQuoteCurrency: state.PORTFOLIO?.quoteCurrency, + defaultAltCurrencyIsoCode: state.APP?.defaultAltCurrency?.isoCode, + }).toUpperCase(); + + await dispatch( + maybePopulatePortfolioForWallets({ + wallets: [wallet], + quoteCurrency, + }) as any, + ); + }, [dispatch, getLatestWalletFromReduxState]); + useLayoutEffect(() => { navigation.setOptions({ headerTitle: () => ( @@ -485,28 +558,33 @@ const WalletDetails: React.FC = ({route}) => { try { await dispatch(startGetRates({})); await Promise.all([ - await dispatch( + dispatch( startUpdateWalletStatus({key, wallet: fullWalletObj, force: true}), - ), - await debouncedLoadHistory(true), + ) as any, + debouncedLoadHistory(true) as any, sleep(1000), ]); dispatch(updatePortfolioBalance()); - setNeedActionTxps(fullWalletObj.pendingTxps); - if (fullWalletObj.isScanning) { + await maybeRefreshWalletBalanceChart(); + + const {wallet: latestWallet} = getLatestWalletFromReduxState(); + setNeedActionTxps(latestWallet?.pendingTxps || []); + + if (latestWallet?.isScanning || fullWalletObj.isScanning) { // cancel scanning if user refreshes in case it's stuck dispatch( setWalletScanning({ - keyId: key.id, - walletId: fullWalletObj.id, + keyId: latestWallet?.keyId || key.id, + walletId: latestWallet?.id || fullWalletObj.id, isScanning: false, }), ); } } catch (err) { dispatch(showBottomNotificationModal(BalanceUpdateError())); + } finally { + setRefreshing(false); } - setRefreshing(false); }; const { @@ -523,10 +601,40 @@ const WalletDetails: React.FC = ({route}) => { pendingTxps, } = uiFormattedWallet; + const displayedFiatBalanceFormat = + typeof selectedFiatBalance === 'number' + ? formatFiatAmount(selectedFiatBalance, defaultAltCurrency.isoCode, { + currencyDisplay: 'symbol', + customPrecision: 'minimal', + }) + : fiatBalanceFormat; + const showFiatBalance = // @ts-ignore Number(cryptoBalance.replaceAll(',', '')) > 0 && network !== Network.testnet; + const formattedCryptoBalance = `${cryptoBalance} ${formatCurrencyAbbreviation( + currencyAbbreviation, + )}`; + const assetTheme = useMemo( + () => + getAssetTheme({ + currencyAbbreviation, + chain, + tokenAddress, + }), + [chain, currencyAbbreviation, tokenAddress], + ); + const chartLineColor = useMemo(() => { + const coinColor = assetTheme?.coinColor; + if (!coinColor) { + return undefined; + } + return theme.dark && coinColor === Black ? White : coinColor; + }, [assetTheme, theme.dark]); + const chartGradientBackgroundColor = useMemo(() => { + return assetTheme?.gradientBackgroundColor; + }, [assetTheme]); const [history, setHistory] = useState([]); const [groupedHistory, setGroupedHistory] = useState([]); @@ -578,6 +686,10 @@ const WalletDetails: React.FC = ({route}) => { try { setIsLoading(!refresh); setErrorLoadingTxs(false); + if (!refresh) { + // Allow one frame for chart/list loaders to render before heavy history work. + await sleep(0); + } const [transactionHistory] = await Promise.all([ dispatch( @@ -635,7 +747,8 @@ const WalletDetails: React.FC = ({route}) => { const updateWalletStatusAndProfileBalance = async () => { await dispatch(startUpdateWalletStatus({key, wallet: fullWalletObj})); - dispatch(updatePortfolioBalance); + dispatch(updatePortfolioBalance()); + await maybeRefreshWalletBalanceChart(); }; useEffect(() => { @@ -1047,6 +1160,24 @@ const WalletDetails: React.FC = ({route}) => { ); const protocolName = getProtocolName(chain, network); + const showEvmGasWalletBadge = + !!fullWalletObj?.credentials?.token && + IsERCToken( + String(currencyAbbreviation || '').toLowerCase(), + String(chain || '').toLowerCase(), + ); + const showActivatedBadge = + ['xrp'].includes(fullWalletObj?.currencyAbbreviation) && + Number(fullWalletObj?.balance?.cryptoConfirmedLocked) >= 10; + const showThresholdBadge = + !IsShared(fullWalletObj) && isTSSKey(key) && !!fullWalletObj.tssMetadata; + const showSpendableRow = !hideAllBalances && showBalanceDetailsButton(); + const hasBottomMetadataRow = + (!!walletType && !showEvmGasWalletBadge) || + showThresholdBadge || + showActivatedBadge; + const hasTopMetadataBadges = + !!protocolName || showSpendableRow || hasBottomMetadataRow; return ( @@ -1058,263 +1189,311 @@ const WalletDetails: React.FC = ({route}) => { onRefresh={onRefresh} /> } - ListHeaderComponent={() => { - return ( - <> - - - { - dispatch(toggleHideAllBalances()); - }}> - {!fullWalletObj.isScanning ? ( - - {!hideAllBalances ? ( - - {cryptoBalance}{' '} - {formatCurrencyAbbreviation(currencyAbbreviation)} - - ) : ( -

****

- )} -
- ) : ( - - -
{t('[Scanning Addresses]')}
-
- -
{t('Please wait...')}
-
-
- )} + ListHeaderComponent={ + <> + + + { + dispatch(toggleHideAllBalances()); + }}> + {!fullWalletObj.isScanning ? ( - {showFiatBalance && - !hideAllBalances && - !fullWalletObj.isScanning && ( - {fiatBalanceFormat} - )} + {!hideAllBalances ? ( + + {showFiatBalance + ? displayedFiatBalanceFormat + : formattedCryptoBalance} + + ) : ( +

****

+ )}
-
- {!hideAllBalances && showBalanceDetailsButton() && ( - setShowBalanceDetailsModal(true)}> - - - - {cryptoSpendableBalance}{' '} - {formatCurrencyAbbreviation(currencyAbbreviation)} - - {showFiatBalance && ( - ({fiatSpendableBalanceFormat}) - )} - - + ) : ( + + +
{t('[Scanning Addresses]')}
+
+ +
{t('Please wait...')}
+
+
)} - - {walletType && ( - - {walletType.icon ? ( - {walletType.icon} - ) : null} - {walletType.title} - - )} - {protocolName ? ( - - - - - {protocolName} - - ) : null} - {IsShared(fullWalletObj) ? ( - - - Multisig {fullWalletObj.credentials.m}/ - {fullWalletObj.credentials.n} - - - ) : isTSSWallet(fullWalletObj) && - fullWalletObj.tssMetadata ? ( - - - Threshold {fullWalletObj.tssMetadata.m}/ - {fullWalletObj.tssMetadata.n} - - - ) : null} - {['xrp', 'sol'].includes( - fullWalletObj?.currencyAbbreviation, - ) ? ( - setShowBalanceDetailsModal(true)}> - - - ) : null} - {['xrp'].includes(fullWalletObj?.currencyAbbreviation) && - Number(fullWalletObj?.balance?.cryptoConfirmedLocked) >= - 10 ? ( - - {t('Activated')} - - ) : null} - -
- - {fullWalletObj ? ( - { - dispatch( - Analytics.track('Clicked Buy Crypto', { - context: 'WalletDetails', - coin: fullWalletObj.currencyAbbreviation, - chain: fullWalletObj.chain || '', - }), - ); - navigation.navigate( - ExternalServicesScreens.ROOT_BUY_AND_SELL, - { - context: 'buyCrypto', - fromWallet: fullWalletObj, - }, - ); - }, - }} - sell={{ - hide: - !fullWalletObj.balance.sat || - (fullWalletObj.network === 'testnet' && - fullWalletObj.currencyAbbreviation !== 'eth' && - fullWalletObj.chain !== 'eth') || - !isCoinSupportedToSell( - fullWalletObj.currencyAbbreviation, - fullWalletObj.chain, - locationData?.countryShortCode || 'US', - ), - cta: () => { - dispatch( - Analytics.track('Clicked Sell Crypto', { - context: 'WalletDetails', - coin: fullWalletObj.currencyAbbreviation, - chain: fullWalletObj.chain || '', - }), - ); - navigation.navigate( - ExternalServicesScreens.ROOT_BUY_AND_SELL, - { - context: 'sellCrypto', - fromWallet: fullWalletObj, - }, - ); - }, - }} - swap={{ - hide: - fullWalletObj.network === 'testnet' || - !isCoinSupportedToSwap( - fullWalletObj.currencyAbbreviation, - fullWalletObj.chain, - ), - cta: () => { - dispatch( - Analytics.track('Clicked Swap Crypto', { - context: 'WalletDetails', - coin: fullWalletObj.currencyAbbreviation, - chain: fullWalletObj.chain || '', - }), - ); - navigation.navigate('SwapCryptoRoot', { - selectedWallet: fullWalletObj, - }); - }, - }} - receive={{ - cta: () => { - dispatch( - Analytics.track('Clicked Receive', { - context: 'WalletDetails', - coin: fullWalletObj.currencyAbbreviation, - chain: fullWalletObj.chain || '', - }), - ); - setShowReceiveAddressBottomModal(true); - }, - }} - send={{ - hide: !fullWalletObj.balance.sat, - cta: () => { - dispatch( - Analytics.track('Clicked Send', { - context: 'WalletDetails', - coin: fullWalletObj.currencyAbbreviation, - chain: fullWalletObj.chain || '', - }), - ); - navigation.navigate('SendTo', {wallet: fullWalletObj}); - }, - }} + + {!hideAllBalances && + !fullWalletObj.isScanning && + showFiatBalance && ( + + {formattedCryptoBalance} + + )} + +
+ + {!hideAllBalances ? ( + + {protocolName ? ( + + {showEvmGasWalletBadge && walletType ? ( + + {walletType.icon ? ( + + {walletType.icon} + + ) : null} + {walletType.title} + + ) : null} + + + + + {protocolName} + + {IsShared(fullWalletObj) ? ( + + + Multisig {fullWalletObj.m}/{fullWalletObj.n} + + + ) : null} + {['xrp', 'sol'].includes( + fullWalletObj?.currencyAbbreviation, + ) ? ( + + setShowBalanceDetailsModal(true) + }> + + + ) : null} + + ) : null} + {showSpendableRow ? ( + setShowBalanceDetailsModal(true)}> + + + + {cryptoSpendableBalance}{' '} + {formatCurrencyAbbreviation( + currencyAbbreviation, + )} + + {showFiatBalance && ( + ({fiatSpendableBalanceFormat}) + )} + + + ) : null} + {hasBottomMetadataRow ? ( + + {walletType && !showEvmGasWalletBadge && ( + + {walletType.icon ? ( + + {walletType.icon} + + ) : null} + {walletType.title} + + )} + {showThresholdBadge ? ( + + + Threshold {fullWalletObj.tssMetadata?.m}/ + {fullWalletObj.tssMetadata?.n} + + + ) : null} + {showActivatedBadge ? ( + + {t('Activated')} + + ) : null} + + ) : null} + + ) : null + } /> ) : null} -
- {pendingTxps && pendingTxps[0] ? ( - <> - -
- {fullWalletObj.credentials.n > 1 - ? t('Pending Proposals') - : t('Unsent Transactions')} -
- - {pendingTxps.length} - -
- {fullWalletObj.credentials.n > 1 && - needActionPendingTxps.length > 0 - ? renderTxp(needActionPendingTxps) - : needActionUnsentTxps.length > 0 - ? renderTxp(needActionUnsentTxps) - : null} - + + + {fullWalletObj ? ( + { + dispatch( + Analytics.track('Clicked Buy Crypto', { + context: 'WalletDetails', + coin: fullWalletObj.currencyAbbreviation, + chain: fullWalletObj.chain || '', + }), + ); + navigation.navigate( + ExternalServicesScreens.ROOT_BUY_AND_SELL, + { + context: 'buyCrypto', + fromWallet: fullWalletObj, + }, + ); + }, + }} + sell={{ + hide: + !fullWalletObj.balance.sat || + (fullWalletObj.network === 'testnet' && + fullWalletObj.currencyAbbreviation !== 'eth' && + fullWalletObj.chain !== 'eth') || + !isCoinSupportedToSell( + fullWalletObj.currencyAbbreviation, + fullWalletObj.chain, + locationData?.countryShortCode || 'US', + ), + cta: () => { + dispatch( + Analytics.track('Clicked Sell Crypto', { + context: 'WalletDetails', + coin: fullWalletObj.currencyAbbreviation, + chain: fullWalletObj.chain || '', + }), + ); + navigation.navigate( + ExternalServicesScreens.ROOT_BUY_AND_SELL, + { + context: 'sellCrypto', + fromWallet: fullWalletObj, + }, + ); + }, + }} + swap={{ + hide: + fullWalletObj.network === 'testnet' || + !isCoinSupportedToSwap( + fullWalletObj.currencyAbbreviation, + fullWalletObj.chain, + ), + cta: () => { + dispatch( + Analytics.track('Clicked Swap Crypto', { + context: 'WalletDetails', + coin: fullWalletObj.currencyAbbreviation, + chain: fullWalletObj.chain || '', + }), + ); + navigation.navigate('SwapCryptoRoot', { + selectedWallet: fullWalletObj, + }); + }, + }} + receive={{ + cta: () => { + dispatch( + Analytics.track('Clicked Receive', { + context: 'WalletDetails', + coin: fullWalletObj.currencyAbbreviation, + chain: fullWalletObj.chain || '', + }), + ); + setShowReceiveAddressBottomModal(true); + }, + }} + send={{ + hide: !fullWalletObj.balance.sat, + cta: () => { + dispatch( + Analytics.track('Clicked Send', { + context: 'WalletDetails', + coin: fullWalletObj.currencyAbbreviation, + chain: fullWalletObj.chain || '', + }), + ); + navigation.navigate('SendTo', {wallet: fullWalletObj}); + }, + }} + /> ) : null} - - {Number(cryptoLockedBalance) > 0 ? ( - setShowBalanceDetailsModal(true)}> - - - {t('Total Locked Balance')} - - - - - - {cryptoLockedBalance}{' '} - {formatCurrencyAbbreviation(currencyAbbreviation)} - - - {network === 'testnet' - ? t('Test Only - No Value') - : fiatLockedBalanceFormat} - - - - ) : null} - - ); - }} + + {pendingTxps && pendingTxps[0] ? ( + <> + +
+ {fullWalletObj.credentials.n > 1 + ? t('Pending Proposals') + : t('Unsent Transactions')} +
+ + {pendingTxps.length} + +
+ {fullWalletObj.credentials.n > 1 && + needActionPendingTxps.length > 0 + ? renderTxp(needActionPendingTxps) + : needActionUnsentTxps.length > 0 + ? renderTxp(needActionUnsentTxps) + : null} + + ) : null} + + {Number(cryptoLockedBalance) > 0 ? ( + setShowBalanceDetailsModal(true)}> + + + {t('Total Locked Balance')} + + + + + + {cryptoLockedBalance}{' '} + {formatCurrencyAbbreviation(currencyAbbreviation)} + + + {network === 'testnet' + ? t('Test Only - No Value') + : fiatLockedBalanceFormat} + + + + ) : null} + + } data={groupedHistory} keyExtractor={keyExtractor} renderItem={({item}) => { diff --git a/src/store/backup/fs-backup.ts b/src/store/backup/fs-backup.ts index f74b086f79..dc8a9fcb6b 100644 --- a/src/store/backup/fs-backup.ts +++ b/src/store/backup/fs-backup.ts @@ -59,6 +59,7 @@ async function _backupPersistRoot(rawJson: string): Promise { const parsed = JSON.parse(rawJson); delete parsed.MARKET_STATS; delete parsed.PORTFOLIO; + delete parsed.PORTFOLIO_CHARTS; delete parsed.RATE; delete parsed.SHOP_CATALOG; filtered = JSON.stringify(parsed); diff --git a/src/store/index.ts b/src/store/index.ts index 4b809139b9..00120d1fb6 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -87,6 +87,10 @@ import { portfolioReducer, portfolioReduxPersistBlackList, } from './portfolio/portfolio.reducer'; +import { + portfolioChartsReducer, + portfolioChartsReduxPersistBlackList, +} from './portfolio-charts/portfolio-charts.reducer'; import {removeWalletSnapshots} from './portfolio/portfolio.actions'; import {WalletActionTypes} from './wallet/wallet.types'; import {BitPayIdActionTypes} from './bitpay-id/bitpay-id.types'; @@ -275,6 +279,7 @@ const reducerPersistBlackLists: Record = { WALLET_CONNECT_V2: walletConnectV2ReduxPersistBlackList, MARKET_STATS: marketStatsReduxPersistBlackList, PORTFOLIO: portfolioReduxPersistBlackList, + PORTFOLIO_CHARTS: portfolioChartsReduxPersistBlackList, }; /* @@ -302,6 +307,7 @@ const reducers = { WALLET_CONNECT_V2: walletConnectV2Reducer, MARKET_STATS: marketStatsReducer, PORTFOLIO: portfolioReducer, + PORTFOLIO_CHARTS: portfolioChartsReducer, }; const combinedReducer = combineReducers(reducers); @@ -470,6 +476,7 @@ const getStore = async () => { 'APP', 'MARKET_STATS', 'PORTFOLIO', + 'PORTFOLIO_CHARTS', 'RATE', 'SHOP', 'SHOP_CATALOG', diff --git a/src/store/portfolio-charts/index.ts b/src/store/portfolio-charts/index.ts new file mode 100644 index 0000000000..dd443e3fcc --- /dev/null +++ b/src/store/portfolio-charts/index.ts @@ -0,0 +1,27 @@ +export { + clearPortfolioCharts, + patchBalanceChartScopeLatestPoints, + pruneBalanceChartCache, + removeBalanceChartScopesByWalletIds, + setHomeChartCollapsed, + touchBalanceChartScope, + upsertBalanceChartScopeTimeframes, +} from './portfolio-charts.actions'; + +export { + portfolioChartsReducer, + portfolioChartsReduxPersistBlackList, +} from './portfolio-charts.reducer'; + +export type { + CachedBalanceChartScope, + CachedBalanceChartTimeframe, + HistoricalRateDependencyMeta, + LatestHoldingsByRateKey, + PortfolioChartsState, +} from './portfolio-charts.models'; + +export { + BALANCE_CHART_CACHE_MAX_SCOPES, + BALANCE_CHART_CACHE_SCHEMA_VERSION, +} from './portfolio-charts.models'; diff --git a/src/store/portfolio-charts/portfolio-charts.actions.ts b/src/store/portfolio-charts/portfolio-charts.actions.ts new file mode 100644 index 0000000000..8ab207ec7c --- /dev/null +++ b/src/store/portfolio-charts/portfolio-charts.actions.ts @@ -0,0 +1,59 @@ +import type {CachedBalanceChartTimeframe} from './portfolio-charts.models'; +import { + PortfolioChartsActionType, + PortfolioChartsActionTypes, +} from './portfolio-charts.types'; + +export const clearPortfolioCharts = (): PortfolioChartsActionType => ({ + type: PortfolioChartsActionTypes.CLEAR_PORTFOLIO_CHARTS, +}); + +export const setHomeChartCollapsed = ( + payload: boolean, +): PortfolioChartsActionType => ({ + type: PortfolioChartsActionTypes.SET_HOME_CHART_COLLAPSED, + payload, +}); + +export const upsertBalanceChartScopeTimeframes = (payload: { + scopeId: string; + walletIds: string[]; + quoteCurrency: string; + balanceOffset: number; + timeframes: CachedBalanceChartTimeframe[]; + lastAccessedAt?: number; +}): PortfolioChartsActionType => ({ + type: PortfolioChartsActionTypes.UPSERT_BALANCE_CHART_SCOPE_TIMEFRAMES, + payload, +}); + +export const patchBalanceChartScopeLatestPoints = (payload: { + scopeId: string; + timeframes: CachedBalanceChartTimeframe[]; + lastAccessedAt?: number; +}): PortfolioChartsActionType => ({ + type: PortfolioChartsActionTypes.PATCH_BALANCE_CHART_SCOPE_LATEST_POINTS, + payload, +}); + +export const touchBalanceChartScope = (payload: { + scopeId: string; + lastAccessedAt?: number; +}): PortfolioChartsActionType => ({ + type: PortfolioChartsActionTypes.TOUCH_BALANCE_CHART_SCOPE, + payload, +}); + +export const pruneBalanceChartCache = (payload?: { + maxScopes?: number; +}): PortfolioChartsActionType => ({ + type: PortfolioChartsActionTypes.PRUNE_BALANCE_CHART_CACHE, + payload, +}); + +export const removeBalanceChartScopesByWalletIds = (payload: { + walletIds: string[]; +}): PortfolioChartsActionType => ({ + type: PortfolioChartsActionTypes.REMOVE_BALANCE_CHART_SCOPES_BY_WALLET_IDS, + payload, +}); diff --git a/src/store/portfolio-charts/portfolio-charts.models.ts b/src/store/portfolio-charts/portfolio-charts.models.ts new file mode 100644 index 0000000000..453e10580b --- /dev/null +++ b/src/store/portfolio-charts/portfolio-charts.models.ts @@ -0,0 +1,68 @@ +import type {FiatRateInterval} from '../rate/rate.models'; + +export const BALANCE_CHART_CACHE_SCHEMA_VERSION = 4; +export const BALANCE_CHART_CACHE_MAX_SCOPES = 40; + +export type HistoricalRateDependencyMeta = { + cacheKey: string; + fetchedOn?: number; + lastTs?: number; +}; + +export type LatestHoldingsByRateKey = Record< + string, + { + units: number; + } +>; + +export type CachedBalanceChartTimeframe = { + timeframe: FiatRateInterval; + builtAt: number; + schemaVersion: number; + + quoteCurrency: string; + balanceOffset: number; + walletIds: string[]; + + snapshotVersionSig: string; + historicalRateDeps: HistoricalRateDependencyMeta[]; + + lastSpotRatesByRateKey: Record; + latestHoldingsByRateKey: LatestHoldingsByRateKey; + latestRemainingCostBasisFiatTotal: number; + + ts: number[]; + totalFiatBalance: number[]; + totalUnrealizedPnlFiat: number[]; + totalPnlPercent: number[]; + minTotalFiatBalance?: number; + minTotalFiatBalanceTs?: number; + maxTotalFiatBalance?: number; + maxTotalFiatBalanceTs?: number; + minTotalFiatBalanceExcludingEnd?: number; + minTotalFiatBalanceExcludingEndTs?: number; + maxTotalFiatBalanceExcludingEnd?: number; + maxTotalFiatBalanceExcludingEndTs?: number; +}; + +export type CachedBalanceChartTimeframes = Partial< + Record +>; + +export type CachedBalanceChartScope = { + scopeId: string; + walletIds: string[]; + quoteCurrency: string; + balanceOffset: number; + lastAccessedAt: number; + timeframes: CachedBalanceChartTimeframes; +}; + +export interface PortfolioChartsState { + homeChartCollapsed: boolean; + homeChartRemountNonce: number; + walletSnapshotVersionById: Record; + cacheByScopeId: Record; + lruScopeIds: string[]; +} diff --git a/src/store/portfolio-charts/portfolio-charts.reducer.ts b/src/store/portfolio-charts/portfolio-charts.reducer.ts new file mode 100644 index 0000000000..8cfdea3d2c --- /dev/null +++ b/src/store/portfolio-charts/portfolio-charts.reducer.ts @@ -0,0 +1,335 @@ +import type {AnyAction} from 'redux'; +import {RateActionTypes} from '../rate/rate.types'; +import {PortfolioActionTypes} from '../portfolio/portfolio.types'; +import type { + CachedBalanceChartScope, + CachedBalanceChartTimeframe, + PortfolioChartsState, +} from './portfolio-charts.models'; +import { + BALANCE_CHART_CACHE_MAX_SCOPES, + BALANCE_CHART_CACHE_SCHEMA_VERSION, +} from './portfolio-charts.models'; +import { + PortfolioChartsActionType, + PortfolioChartsActionTypes, +} from './portfolio-charts.types'; + +export type PortfolioChartsReduxPersistBlackList = string[]; +export const portfolioChartsReduxPersistBlackList: PortfolioChartsReduxPersistBlackList = + []; + +const initialState: PortfolioChartsState = { + homeChartCollapsed: false, + homeChartRemountNonce: 0, + walletSnapshotVersionById: {}, + cacheByScopeId: {}, + lruScopeIds: [], +}; + +const normalizeWalletIds = (walletIds: string[]): string[] => { + const seen = new Set(); + const out: string[] = []; + for (const walletId of walletIds || []) { + const normalized = String(walletId || ''); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + out.push(normalized); + } + return out.sort((a, b) => a.localeCompare(b)); +}; + +const normalizeBalanceOffset = (value: number): number => { + const normalized = typeof value === 'number' ? value : Number(value); + return Number.isFinite(normalized) ? normalized : 0; +}; + +const touchScopeId = (lruScopeIds: string[], scopeId: string): string[] => { + if (!scopeId) { + return lruScopeIds; + } + const next = lruScopeIds.filter(id => id !== scopeId); + next.unshift(scopeId); + return next; +}; + +const dedupeLruScopeIds = (lruScopeIds: string[]): string[] => { + const seen = new Set(); + const next: string[] = []; + + for (const scopeId of lruScopeIds) { + if (seen.has(scopeId)) { + continue; + } + seen.add(scopeId); + next.push(scopeId); + } + + return next; +}; + +const pruneCacheState = ( + state: PortfolioChartsState, + maxScopes = BALANCE_CHART_CACHE_MAX_SCOPES, +): PortfolioChartsState => { + const effectiveMax = Math.max(1, Math.floor(maxScopes || 1)); + const dedupedLruScopeIds = dedupeLruScopeIds(state.lruScopeIds); + + if (dedupedLruScopeIds.length <= effectiveMax) { + if (dedupedLruScopeIds.length === state.lruScopeIds.length) { + return state; + } + + return { + ...state, + lruScopeIds: dedupedLruScopeIds, + }; + } + + const keepScopeIds = dedupedLruScopeIds.slice(0, effectiveMax); + const nextCacheByScopeId: PortfolioChartsState['cacheByScopeId'] = {}; + + for (const scopeId of keepScopeIds) { + const scope = state.cacheByScopeId[scopeId]; + if (scope) { + nextCacheByScopeId[scopeId] = scope; + } + } + + return { + ...state, + cacheByScopeId: nextCacheByScopeId, + lruScopeIds: keepScopeIds, + }; +}; + +const removeScopesForWalletIds = ( + state: PortfolioChartsState, + walletIds: string[], +): PortfolioChartsState => { + const targetWalletIds = new Set(normalizeWalletIds(walletIds)); + if (!targetWalletIds.size) { + return state; + } + + const nextCacheByScopeId = {...state.cacheByScopeId}; + const nextLruScopeIds: string[] = []; + + for (const scopeId of state.lruScopeIds) { + const scope = nextCacheByScopeId[scopeId]; + const includesWallet = scope?.walletIds?.some(walletId => + targetWalletIds.has(walletId), + ); + if (includesWallet) { + delete nextCacheByScopeId[scopeId]; + continue; + } + nextLruScopeIds.push(scopeId); + } + + return { + ...state, + cacheByScopeId: nextCacheByScopeId, + lruScopeIds: nextLruScopeIds, + }; +}; + +const sanitizeTimeframe = ( + timeframe: CachedBalanceChartTimeframe, +): CachedBalanceChartTimeframe => ({ + ...timeframe, + schemaVersion: + typeof timeframe?.schemaVersion === 'number' + ? timeframe.schemaVersion + : BALANCE_CHART_CACHE_SCHEMA_VERSION, + balanceOffset: normalizeBalanceOffset(timeframe?.balanceOffset ?? 0), + walletIds: normalizeWalletIds(timeframe?.walletIds || []), + historicalRateDeps: Array.isArray(timeframe?.historicalRateDeps) + ? timeframe.historicalRateDeps.filter(dep => !!dep?.cacheKey) + : [], + lastSpotRatesByRateKey: {...(timeframe?.lastSpotRatesByRateKey || {})}, + latestHoldingsByRateKey: {...(timeframe?.latestHoldingsByRateKey || {})}, + ts: Array.isArray(timeframe?.ts) ? timeframe.ts.slice() : [], + totalFiatBalance: Array.isArray(timeframe?.totalFiatBalance) + ? timeframe.totalFiatBalance.slice() + : [], + totalUnrealizedPnlFiat: Array.isArray(timeframe?.totalUnrealizedPnlFiat) + ? timeframe.totalUnrealizedPnlFiat.slice() + : [], + totalPnlPercent: Array.isArray(timeframe?.totalPnlPercent) + ? timeframe.totalPnlPercent.slice() + : [], +}); + +const upsertScopeTimeframes = ( + state: PortfolioChartsState, + args: { + scopeId: string; + walletIds?: string[]; + quoteCurrency?: string; + balanceOffset?: number; + timeframes: CachedBalanceChartTimeframe[]; + lastAccessedAt?: number; + }, +): PortfolioChartsState => { + const scopeId = String(args.scopeId || ''); + if (!scopeId) { + return state; + } + + const existingScope = state.cacheByScopeId[scopeId]; + const nextTimeframes = { + ...(existingScope?.timeframes || {}), + } as CachedBalanceChartScope['timeframes']; + + for (const timeframe of args.timeframes || []) { + if (!timeframe?.timeframe) { + continue; + } + nextTimeframes[timeframe.timeframe] = sanitizeTimeframe(timeframe); + } + + const nextScope: CachedBalanceChartScope = { + scopeId, + walletIds: normalizeWalletIds( + args.walletIds || existingScope?.walletIds || [], + ), + quoteCurrency: String( + args.quoteCurrency || existingScope?.quoteCurrency || '', + ).toUpperCase(), + balanceOffset: normalizeBalanceOffset( + args.balanceOffset ?? existingScope?.balanceOffset ?? 0, + ), + lastAccessedAt: + typeof args.lastAccessedAt === 'number' + ? args.lastAccessedAt + : Date.now(), + timeframes: nextTimeframes, + }; + + return pruneCacheState({ + ...state, + cacheByScopeId: { + ...state.cacheByScopeId, + [scopeId]: nextScope, + }, + lruScopeIds: touchScopeId(state.lruScopeIds, scopeId), + }); +}; + +export const portfolioChartsReducer = ( + state: PortfolioChartsState = initialState, + action: PortfolioChartsActionType | AnyAction, +): PortfolioChartsState => { + switch (action.type) { + case PortfolioChartsActionTypes.CLEAR_PORTFOLIO_CHARTS: + case PortfolioActionTypes.CLEAR_PORTFOLIO: + return { + ...initialState, + homeChartRemountNonce: (state.homeChartRemountNonce || 0) + 1, + }; + + case PortfolioChartsActionTypes.SET_HOME_CHART_COLLAPSED: + return { + ...state, + homeChartCollapsed: !!action.payload, + }; + + case PortfolioChartsActionTypes.UPSERT_BALANCE_CHART_SCOPE_TIMEFRAMES: + return upsertScopeTimeframes(state, action.payload || {timeframes: []}); + + case PortfolioChartsActionTypes.PATCH_BALANCE_CHART_SCOPE_LATEST_POINTS: { + const scopeId = String(action.payload?.scopeId || ''); + const existingScope = state.cacheByScopeId[scopeId]; + if (!scopeId || !existingScope) { + return state; + } + return upsertScopeTimeframes(state, { + scopeId, + walletIds: existingScope.walletIds, + quoteCurrency: existingScope.quoteCurrency, + balanceOffset: existingScope.balanceOffset, + timeframes: action.payload?.timeframes || [], + lastAccessedAt: action.payload?.lastAccessedAt, + }); + } + + case PortfolioChartsActionTypes.TOUCH_BALANCE_CHART_SCOPE: { + const scopeId = String(action.payload?.scopeId || ''); + const scope = state.cacheByScopeId[scopeId]; + if (!scopeId || !scope) { + return state; + } + + return pruneCacheState({ + ...state, + cacheByScopeId: { + ...state.cacheByScopeId, + [scopeId]: { + ...scope, + lastAccessedAt: + typeof action.payload?.lastAccessedAt === 'number' + ? action.payload.lastAccessedAt + : Date.now(), + }, + }, + lruScopeIds: touchScopeId(state.lruScopeIds, scopeId), + }); + } + + case PortfolioChartsActionTypes.PRUNE_BALANCE_CHART_CACHE: + return pruneCacheState( + state, + action.payload?.maxScopes ?? BALANCE_CHART_CACHE_MAX_SCOPES, + ); + + case PortfolioChartsActionTypes.REMOVE_BALANCE_CHART_SCOPES_BY_WALLET_IDS: + return removeScopesForWalletIds(state, action.payload?.walletIds || []); + + case PortfolioActionTypes.SET_WALLET_SNAPSHOTS: { + const walletId = String(action.payload?.walletId || ''); + if (!walletId) { + return state; + } + const prevVersion = state.walletSnapshotVersionById[walletId] || 0; + return { + ...state, + walletSnapshotVersionById: { + ...state.walletSnapshotVersionById, + [walletId]: prevVersion + 1, + }, + }; + } + + case PortfolioActionTypes.REMOVE_WALLET_SNAPSHOTS: { + const walletIds = normalizeWalletIds(action.payload?.walletIds || []); + if (!walletIds.length) { + return state; + } + + const nextState = removeScopesForWalletIds(state, walletIds); + const nextWalletSnapshotVersionById = { + ...nextState.walletSnapshotVersionById, + }; + for (const walletId of walletIds) { + delete nextWalletSnapshotVersionById[walletId]; + } + return { + ...nextState, + walletSnapshotVersionById: nextWalletSnapshotVersionById, + }; + } + + case RateActionTypes.CLEAR_RATE_STATE: + return { + ...state, + cacheByScopeId: {}, + lruScopeIds: [], + }; + + default: + return state; + } +}; diff --git a/src/store/portfolio-charts/portfolio-charts.types.ts b/src/store/portfolio-charts/portfolio-charts.types.ts new file mode 100644 index 0000000000..e2d312227a --- /dev/null +++ b/src/store/portfolio-charts/portfolio-charts.types.ts @@ -0,0 +1,72 @@ +import type {CachedBalanceChartTimeframe} from './portfolio-charts.models'; + +export enum PortfolioChartsActionTypes { + CLEAR_PORTFOLIO_CHARTS = 'PORTFOLIO_CHARTS/CLEAR_PORTFOLIO_CHARTS', + SET_HOME_CHART_COLLAPSED = 'PORTFOLIO_CHARTS/SET_HOME_CHART_COLLAPSED', + UPSERT_BALANCE_CHART_SCOPE_TIMEFRAMES = 'PORTFOLIO_CHARTS/UPSERT_BALANCE_CHART_SCOPE_TIMEFRAMES', + PATCH_BALANCE_CHART_SCOPE_LATEST_POINTS = 'PORTFOLIO_CHARTS/PATCH_BALANCE_CHART_SCOPE_LATEST_POINTS', + TOUCH_BALANCE_CHART_SCOPE = 'PORTFOLIO_CHARTS/TOUCH_BALANCE_CHART_SCOPE', + PRUNE_BALANCE_CHART_CACHE = 'PORTFOLIO_CHARTS/PRUNE_BALANCE_CHART_CACHE', + REMOVE_BALANCE_CHART_SCOPES_BY_WALLET_IDS = 'PORTFOLIO_CHARTS/REMOVE_BALANCE_CHART_SCOPES_BY_WALLET_IDS', +} + +export interface ClearPortfolioChartsAction { + type: typeof PortfolioChartsActionTypes.CLEAR_PORTFOLIO_CHARTS; +} + +export interface SetHomeChartCollapsedAction { + type: typeof PortfolioChartsActionTypes.SET_HOME_CHART_COLLAPSED; + payload: boolean; +} + +export interface UpsertBalanceChartScopeTimeframesAction { + type: typeof PortfolioChartsActionTypes.UPSERT_BALANCE_CHART_SCOPE_TIMEFRAMES; + payload: { + scopeId: string; + walletIds: string[]; + quoteCurrency: string; + balanceOffset: number; + timeframes: CachedBalanceChartTimeframe[]; + lastAccessedAt?: number; + }; +} + +export interface PatchBalanceChartScopeLatestPointsAction { + type: typeof PortfolioChartsActionTypes.PATCH_BALANCE_CHART_SCOPE_LATEST_POINTS; + payload: { + scopeId: string; + timeframes: CachedBalanceChartTimeframe[]; + lastAccessedAt?: number; + }; +} + +export interface TouchBalanceChartScopeAction { + type: typeof PortfolioChartsActionTypes.TOUCH_BALANCE_CHART_SCOPE; + payload: { + scopeId: string; + lastAccessedAt?: number; + }; +} + +export interface PruneBalanceChartCacheAction { + type: typeof PortfolioChartsActionTypes.PRUNE_BALANCE_CHART_CACHE; + payload?: { + maxScopes?: number; + }; +} + +export interface RemoveBalanceChartScopesByWalletIdsAction { + type: typeof PortfolioChartsActionTypes.REMOVE_BALANCE_CHART_SCOPES_BY_WALLET_IDS; + payload: { + walletIds: string[]; + }; +} + +export type PortfolioChartsActionType = + | ClearPortfolioChartsAction + | SetHomeChartCollapsedAction + | UpsertBalanceChartScopeTimeframesAction + | PatchBalanceChartScopeLatestPointsAction + | TouchBalanceChartScopeAction + | PruneBalanceChartCacheAction + | RemoveBalanceChartScopesByWalletIdsAction; diff --git a/src/store/portfolio/portfolio.effects.ts b/src/store/portfolio/portfolio.effects.ts index 7a97ba1596..78e83a3031 100644 --- a/src/store/portfolio/portfolio.effects.ts +++ b/src/store/portfolio/portfolio.effects.ts @@ -19,7 +19,7 @@ import { GetTransactionHistory, } from '../wallet/effects/transactions/transactions'; import {GetPrecision} from '../wallet/utils/currency'; -import type {Wallet} from '../wallet/wallet.models'; +import type {Key, Wallet} from '../wallet/wallet.models'; import { getRateByCurrencyName, getErrorString, @@ -53,6 +53,7 @@ import { getSnapshotAtomicBalanceFromCryptoBalance, getWalletLiveAtomicBalance, getVisibleWalletsFromKeys, + walletHasNonZeroLiveBalance, } from '../../utils/portfolio/assets'; import {logManager} from '../../managers/LogManager'; import {shouldDisablePopulateForLargeHistory} from './portfolio.utils'; @@ -179,21 +180,6 @@ const getVisibleMainnetWalletsFromState = (state: RootState): Wallet[] => { ); }; -const walletHasNonZeroLiveBalance = (wallet: Wallet): boolean => { - const sat = (wallet as any)?.balance?.sat; - if (typeof sat === 'number' && Number.isFinite(sat)) { - return sat > 0; - } - - const crypto = (wallet as any)?.balance?.crypto; - if (typeof crypto === 'string') { - const parsed = Number(crypto.replace(/,/g, '')); - return Number.isFinite(parsed) ? parsed > 0 : false; - } - - return false; -}; - const getWalletLiveFiatBalanceSortValue = (wallet: Wallet): number => { const fiatCandidates = [ (wallet as any)?.balance?.totalBalance, @@ -430,11 +416,44 @@ export const maybePopulatePortfolioForWallets = const prevMismatchesByWalletId = state.PORTFOLIO?.snapshotBalanceMismatchesByWalletId || {}; - const walletsScope = Array.isArray(args.wallets) ? args.wallets : []; - if (!walletsScope.length) { + // Some call sites can hand us wallet objects captured before a balance + // refresh finishes. Re-hydrate by id from the latest Redux state so + // snapshot repopulation always uses the newest live balances when possible. + const walletsScopeInput = Array.isArray(args.wallets) ? args.wallets : []; + if (!walletsScopeInput.length) { return; } + const walletIdsScope = walletsScopeInput + .map(w => String((w as any)?.id || '')) + .filter(Boolean); + + const keys = (state.WALLET?.keys || {}) as Record; + const allWalletsFromState = (Object.values(keys) as Key[]).flatMap( + (walletKey: Key) => walletKey.wallets || [], + ); + const walletsByIdFromState = new Map( + allWalletsFromState + .filter(w => !!w?.id) + .map(w => [String(w.id), w] as const), + ); + const fallbackWalletsById = new Map( + walletsScopeInput + .filter(w => !!w?.id) + .map(w => [String(w.id), w] as const), + ); + + const walletsScope: Wallet[] = walletIdsScope.length + ? Array.from(new Set(walletIdsScope)) + .map(walletId => { + return ( + walletsByIdFromState.get(walletId) || + fallbackWalletsById.get(walletId) + ); + }) + .filter((w): w is Wallet => !!w) + : walletsScopeInput; + const {walletIdsToPopulate, snapshotBalanceMismatchUpdates} = getWalletIdsToPopulateFromSnapshots({ wallets: walletsScope, @@ -467,11 +486,11 @@ export const maybePopulatePortfolioForWallets = }); if (walletIdsWithCurrentRates.length) { - dispatch( + await dispatch( populatePortfolio({ quoteCurrency, walletIds: walletIdsWithCurrentRates, - }), + }) as any, ); } }; @@ -573,9 +592,18 @@ const ensureFiatRateSeriesInterval = async (args: { currencyAbbreviation: string; interval: FiatRateInterval; allowedCoins?: string[]; + chain?: string; + tokenAddress?: string; }): Promise => { - const {dispatch, fiatCode, currencyAbbreviation, interval, allowedCoins} = - args; + const { + dispatch, + fiatCode, + currencyAbbreviation, + interval, + allowedCoins, + chain, + tokenAddress, + } = args; const coinForCacheCheck = normalizeFiatRateSeriesCoin(currencyAbbreviation); return dispatch( fetchFiatRateSeriesInterval({ @@ -583,18 +611,25 @@ const ensureFiatRateSeriesInterval = async (args: { interval, coinForCacheCheck, allowedCoins, + chain: tokenAddress ? chain : undefined, + tokenAddress, }), ); }; -const getLoadedFiatRateSeriesIntervalKey = (args: { +export const getLoadedFiatRateSeriesIntervalKey = (args: { fiatCode: string; currencyAbbreviation: string; interval: FiatRateInterval; + chain?: string; + tokenAddress?: string; }): string => { const fiatCode = (args.fiatCode || '').toUpperCase(); const coin = normalizeFiatRateSeriesCoin(args.currencyAbbreviation); - return `${fiatCode}:${coin}:${args.interval}`; + return getFiatRateSeriesCacheKey(fiatCode, coin, args.interval, { + chain: args.tokenAddress ? args.chain : undefined, + tokenAddress: args.tokenAddress, + }); }; const ensureFiatRateSeriesIntervalOnce = async (args: { @@ -604,6 +639,8 @@ const ensureFiatRateSeriesIntervalOnce = async (args: { currencyAbbreviation: string; interval: FiatRateInterval; allowedCoins?: string[]; + chain?: string; + tokenAddress?: string; }): Promise => { const { dispatch, @@ -612,11 +649,15 @@ const ensureFiatRateSeriesIntervalOnce = async (args: { currencyAbbreviation, interval, allowedCoins, + chain, + tokenAddress, } = args; const loadedIntervalKey = getLoadedFiatRateSeriesIntervalKey({ fiatCode, currencyAbbreviation, interval, + chain, + tokenAddress, }); if (loadedIntervals.has(loadedIntervalKey)) { return true; @@ -628,6 +669,8 @@ const ensureFiatRateSeriesIntervalOnce = async (args: { currencyAbbreviation, interval, allowedCoins, + chain, + tokenAddress, }); }; @@ -653,10 +696,15 @@ const hasFiatRateSeriesPointsInCache = (args: { fiatCode: string; currencyAbbreviation: string; interval: FiatRateInterval; + chain?: string; + tokenAddress?: string; }): boolean => { const fiatCode = (args.fiatCode || '').toUpperCase(); const coin = normalizeFiatRateSeriesCoin(args.currencyAbbreviation); - const cacheKey = getFiatRateSeriesCacheKey(fiatCode, coin, args.interval); + const cacheKey = getFiatRateSeriesCacheKey(fiatCode, coin, args.interval, { + chain: args.tokenAddress ? args.chain : undefined, + tokenAddress: args.tokenAddress, + }); const series = args.getState().RATE?.fiatRateSeriesCache?.[cacheKey]; return Array.isArray(series?.points) && series.points.length > 0; }; @@ -667,6 +715,8 @@ const ensureWalletHasHistoricalFiatRates = async (args: { loadedIntervals: Set; fiatCode: string; currencyAbbreviation: string; + chain?: string; + tokenAddress?: string; }): Promise => { if ( hasFiatRateSeriesPointsInCache({ @@ -674,6 +724,8 @@ const ensureWalletHasHistoricalFiatRates = async (args: { fiatCode: args.fiatCode, currencyAbbreviation: args.currencyAbbreviation, interval: 'ALL', + chain: args.tokenAddress ? args.chain : undefined, + tokenAddress: args.tokenAddress, }) ) { return true; @@ -685,6 +737,8 @@ const ensureWalletHasHistoricalFiatRates = async (args: { fiatCode: args.fiatCode, currencyAbbreviation: args.currencyAbbreviation, interval: 'ALL', + chain: args.tokenAddress ? args.chain : undefined, + tokenAddress: args.tokenAddress, }); if (!didFetch) { // Best-effort fetch failed; treat as unavailable unless cache already exists. @@ -693,6 +747,8 @@ const ensureWalletHasHistoricalFiatRates = async (args: { fiatCode: args.fiatCode, currencyAbbreviation: args.currencyAbbreviation, interval: 'ALL', + chain: args.tokenAddress ? args.chain : undefined, + tokenAddress: args.tokenAddress, }); } @@ -701,6 +757,8 @@ const ensureWalletHasHistoricalFiatRates = async (args: { fiatCode: args.fiatCode, currencyAbbreviation: args.currencyAbbreviation, interval: 'ALL', + chain: args.tokenAddress ? args.chain : undefined, + tokenAddress: args.tokenAddress, }); }; @@ -732,13 +790,28 @@ export const populatePortfolio = ); const wallets = getVisibleMainnetWalletsFromState(state); + const existingSnapshotsByWalletId = + state.PORTFOLIO?.snapshotsByWalletId || {}; const walletIdsFilter = Array.isArray(args?.walletIds) ? new Set(args?.walletIds) : undefined; const walletsToPopulateUnordered = ( walletIdsFilter ? wallets.filter(w => walletIdsFilter.has(w.id)) : wallets - ).filter(walletHasNonZeroLiveBalance); + ).filter(wallet => { + if (walletHasNonZeroLiveBalance(wallet)) { + return true; + } + + // When a specific wallet subset is explicitly requested (for example, + // after a send drains a wallet to zero), still allow that wallet to be + // repopulated if it already has snapshots that now mismatch its live + // balance. + return !!( + walletIdsFilter && + getLatestSnapshot(existingSnapshotsByWalletId[wallet.id]) + ); + }); let walletsToPopulate = sortWalletsByAssetAndBalanceDesc( walletsToPopulateUnordered, ); @@ -790,6 +863,8 @@ export const populatePortfolio = loadedIntervals: preflightLoadedIntervals, fiatCode: targetQuoteCurrency, currencyAbbreviation: wallet.currencyAbbreviation, + chain: wallet.tokenAddress ? wallet.chain : undefined, + tokenAddress: wallet.tokenAddress || undefined, }); if (typeof cachedHistoricalSupport !== 'boolean') { @@ -941,6 +1016,8 @@ export const populatePortfolio = loadedIntervals, fiatCode: targetQuoteCurrency, currencyAbbreviation: wallet.currencyAbbreviation, + chain: wallet.tokenAddress ? wallet.chain : undefined, + tokenAddress: wallet.tokenAddress || undefined, }); if (typeof cachedHistoricalSupport !== 'boolean') { @@ -1192,6 +1269,8 @@ export const populatePortfolio = fiatCode: quoteCurrency, currencyAbbreviation: wallet.currencyAbbreviation, interval, + chain: wallet.tokenAddress ? wallet.chain : undefined, + tokenAddress: wallet.tokenAddress || undefined, }); } @@ -1207,7 +1286,7 @@ export const populatePortfolio = // IMPORTANT: the shared PnL engine expects these exact field names // (walletId / walletName / balanceAtomic / balanceFormatted). walletId: wallet.id, - walletName: wallet.name, + walletName: wallet.walletName, chain: String(wallet.chain || '').toLowerCase(), network: wallet.network, currencyAbbreviation: String( @@ -1442,7 +1521,7 @@ export const preparePortfolioFiatRateCachesForQuoteCurrencySwitch = const sourceQuoteCurrencies = new Set(); for (const wallet of wallets) { const snapshots = snapshotsByWalletId[wallet.id]; - const latest = getLatestSnapshot(snapshots); + const latest = getLatestSnapshot(snapshots); const quote = (latest?.quoteCurrency || '').toUpperCase(); if (!quote || quote === targetQuoteCurrency) { continue; diff --git a/src/store/portfolio/portfolio.models.ts b/src/store/portfolio/portfolio.models.ts index 17d679c836..6d006e8e1a 100644 --- a/src/store/portfolio/portfolio.models.ts +++ b/src/store/portfolio/portfolio.models.ts @@ -3,6 +3,7 @@ export type BalanceSnapshotDirection = 'incoming' | 'outgoing'; export interface BalanceSnapshot { id: string; + walletId?: string; chain: string; coin: string; network: string; @@ -23,6 +24,10 @@ export interface BalanceSnapshot { createdAt?: number; } +export type BalanceSnapshotsByWalletId = { + [walletId: string]: BalanceSnapshot[] | undefined; +}; + export interface PortfolioPopulateError { walletId: string; message: string; @@ -52,7 +57,7 @@ export interface PortfolioPopulateStatus { } export interface PortfolioState { - snapshotsByWalletId: {[walletId: string]: BalanceSnapshot[] | undefined}; + snapshotsByWalletId: BalanceSnapshotsByWalletId; lastPopulatedAt?: number; quoteCurrency?: string; populateDisabled: boolean; diff --git a/src/store/rate/rate.models.ts b/src/store/rate/rate.models.ts index 7bce09c0cc..6db90618d8 100644 --- a/src/store/rate/rate.models.ts +++ b/src/store/rate/rate.models.ts @@ -1,3 +1,12 @@ +import { + type FiatRateSeriesAssetIdentity, + type FiatRateSeriesReaderIdentity, + getFiatRateSeriesAssetKey as getSharedFiatRateSeriesAssetKey, + getFiatRateSeriesCacheKey as getSharedFiatRateSeriesCacheKey, + parseFiatRateSeriesCacheKey as parseSharedFiatRateSeriesCacheKey, +} from '../../utils/portfolio/core/fiatRateSeries'; +import {HISTORIC_RATES_CACHE_DURATION} from '../../constants/wallet'; + export interface Rate { code: string; fetchedOn: number; @@ -53,25 +62,36 @@ export type FiatRateSeriesCache = { [key in string]?: FiatRateSeries; }; +export type FiatRateSeriesCacheEntry = NonNullable; +export type {FiatRateSeriesAssetIdentity, FiatRateSeriesReaderIdentity}; + export type RatesCacheKey = { [key: number]: number | undefined; }; +export const getFiatRateSeriesAssetKey = getSharedFiatRateSeriesAssetKey; +export const parseFiatRateSeriesCacheKey = parseSharedFiatRateSeriesCacheKey; + export const getFiatRateSeriesCacheKey = ( fiatCode: string, coin: string, interval: FiatRateInterval, + identity?: FiatRateSeriesReaderIdentity, ): string => { - return `${(fiatCode || '').toUpperCase()}:${( - coin || '' - ).toLowerCase()}:${interval}`; + return getSharedFiatRateSeriesCacheKey(fiatCode, coin, interval, identity); }; +export const getFiatRateSeriesLoadedIntervalKey = getFiatRateSeriesCacheKey; + export const hasValidSeriesForCoin = (args: { cache: FiatRateSeriesCache | undefined; fiatCodeUpper: string; normalizedCoin: string; intervals: ReadonlyArray; + requireFresh?: boolean; + freshnessDurationSeconds?: number; + chain?: string; + tokenAddress?: string; }): boolean => { const fiatCodeUpper = (args.fiatCodeUpper || '').toUpperCase(); const normalizedCoin = (args.normalizedCoin || '').trim().toLowerCase(); @@ -84,8 +104,13 @@ export const hasValidSeriesForCoin = (args: { fiatCodeUpper, normalizedCoin, interval, + { + chain: args.chain, + tokenAddress: args.tokenAddress, + }, ); - const points = args.cache?.[cacheKey]?.points; + const cachedSeries = args.cache?.[cacheKey]; + const points = cachedSeries?.points; if (!Array.isArray(points) || !points.length) { return false; } @@ -96,6 +121,22 @@ export const hasValidSeriesForCoin = (args: { ) { return false; } + if (args.requireFresh) { + const freshnessDurationSeconds = Math.max( + 0, + args.freshnessDurationSeconds ?? HISTORIC_RATES_CACHE_DURATION, + ); + const fetchedOn = cachedSeries?.fetchedOn; + if ( + !( + typeof fetchedOn === 'number' && + Number.isFinite(fetchedOn) && + Date.now() - fetchedOn <= freshnessDurationSeconds * 1000 + ) + ) { + return false; + } + } } return true; diff --git a/src/store/rate/rate.reducer.ts b/src/store/rate/rate.reducer.ts index a783390527..5d46b55663 100644 --- a/src/store/rate/rate.reducer.ts +++ b/src/store/rate/rate.reducer.ts @@ -1,4 +1,5 @@ import type {FiatRateSeriesCache, Rates, RatesCacheKey} from './rate.models'; +import {parseFiatRateSeriesCacheKey} from './rate.models'; import {RateActionType, RateActionTypes} from './rate.types'; import {DEFAULT_DATE_RANGE} from '../../constants/rate'; @@ -8,29 +9,11 @@ export const rateReduxPersistBlackList: RateReduxPersistBlackList = []; const getFiatCodeFromSeriesCacheKey = ( cacheKey: string, ): string | undefined => { - if (!cacheKey || typeof cacheKey !== 'string') { - return undefined; - } - const idx = cacheKey.indexOf(':'); - if (idx <= 0) { - return undefined; - } - return cacheKey.slice(0, idx).toUpperCase(); + return parseFiatRateSeriesCacheKey(cacheKey)?.fiatCode; }; const getCoinFromSeriesCacheKey = (cacheKey: string): string | undefined => { - if (!cacheKey || typeof cacheKey !== 'string') { - return undefined; - } - const first = cacheKey.indexOf(':'); - if (first <= 0) { - return undefined; - } - const second = cacheKey.indexOf(':', first + 1); - if (second <= first + 1) { - return undefined; - } - return cacheKey.slice(first + 1, second).toLowerCase(); + return parseFiatRateSeriesCacheKey(cacheKey)?.coin; }; export interface RateState { diff --git a/src/store/shop/shop.actions.ts b/src/store/shop/shop.actions.ts index 462a7034ba..3cf4b238ec 100644 --- a/src/store/shop/shop.actions.ts +++ b/src/store/shop/shop.actions.ts @@ -12,6 +12,10 @@ import { } from './shop.models'; import {Network} from '../../constants'; +export const clearShopStore = (): ShopActionType => ({ + type: ShopActionTypes.CLEAR_SHOP_STORE, +}); + export const successFetchCatalog = (payload: { availableCardMap: CardConfigMap; categoriesAndCurations: CategoriesAndCurations; @@ -152,9 +156,7 @@ export const clearedShopCatalogFields = (): ShopActionType => ({ type: ShopActionTypes.CLEARED_SHOP_CATALOG_FIELDS, }); -export const isJoinedWaitlist = ( - isJoinedWaitlist: boolean, -): ShopActionType => ({ +export const isJoinedWaitlist = (joinedWaitlist: boolean): ShopActionType => ({ type: ShopActionTypes.IS_JOINED_WAITLIST, - payload: {isJoinedWaitlist}, + payload: {isJoinedWaitlist: joinedWaitlist}, }); diff --git a/src/store/shop/shop.reducer.ts b/src/store/shop/shop.reducer.ts index 52d9e0737e..71d72abf08 100644 --- a/src/store/shop/shop.reducer.ts +++ b/src/store/shop/shop.reducer.ts @@ -71,6 +71,8 @@ export const shopReducer = ( action: ShopActionType, ): ShopState => { switch (action.type) { + case ShopActionTypes.CLEAR_SHOP_STORE: + return initialShopState; case ShopActionTypes.SUCCESS_FETCH_CATALOG: const {availableCardMap, categoriesAndCurations, integrations} = action.payload; diff --git a/src/store/shop/shop.types.ts b/src/store/shop/shop.types.ts index 521527d804..3f6b53fe68 100644 --- a/src/store/shop/shop.types.ts +++ b/src/store/shop/shop.types.ts @@ -12,6 +12,7 @@ import { } from './shop.models'; export enum ShopActionTypes { + CLEAR_SHOP_STORE = 'SHOP/CLEAR_SHOP_STORE', SUCCESS_FETCH_CATALOG = 'SHOP/SUCCESS_FETCH_CATALOG', FAILED_FETCH_CATALOG = 'SHOP/FAILED_FETCH_CATALOG', SUCCESS_CREATE_GIFT_CARD_INVOICE = 'SHOP/SUCCESS_CREATE_GIFT_CARD_INVOICE', @@ -35,6 +36,10 @@ export enum ShopActionTypes { IS_JOINED_WAITLIST = 'SHOP/IS_JOINED_WAITLIST', } +interface clearShopStore { + type: typeof ShopActionTypes.CLEAR_SHOP_STORE; +} + interface successFetchCatalog { type: typeof ShopActionTypes.SUCCESS_FETCH_CATALOG; payload: { @@ -160,6 +165,7 @@ interface isJoinedWaitlist { } export type ShopActionType = + | clearShopStore | successFetchCatalog | failedFetchCatalog | hidGiftCardCoupon diff --git a/src/store/wallet/effects/currencies/currencies.ts b/src/store/wallet/effects/currencies/currencies.ts index 29f63581f2..569319ea64 100644 --- a/src/store/wallet/effects/currencies/currencies.ts +++ b/src/store/wallet/effects/currencies/currencies.ts @@ -24,6 +24,20 @@ import merge from 'lodash.merge'; import {tokenManager} from '../../../../managers/TokenManager'; import {logManager} from '../../../../managers/LogManager'; +const TOKEN_OPTIONS_YIELD_EVERY = 150; + +const yieldToEventLoop = (): Promise => { + return new Promise(resolve => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => { + setTimeout(resolve, 0); + }); + return; + } + setTimeout(resolve, 0); + }); +}; + export const startGetTokenOptions = (): Effect> => async dispatch => { try { @@ -31,13 +45,13 @@ export const startGetTokenOptions = let tokenOptionsByAddress: {[key in string]: Token} = {}; let tokenDataByAddress: {[key in string]: CurrencyOpts} = {}; for await (const chain of SUPPORTED_VM_TOKENS) { - let tokens = {} as {[key in string]: Token}; + let tokens: Token[] = []; try { - const {data} = await axios.get<{[key in string]: Token}>( + const {data} = await axios.get( `${BASE_BWS_URL}/v1/service/oneInch/getTokens/${chain}`, ); tokens = data; - } catch (error) { + } catch { logManager.info( `request: ${BASE_BWS_URL}/v1/service/oneInch/getTokens/${chain} failed - continue anyway [startGetTokenOptions]`, ); @@ -49,11 +63,12 @@ export const startGetTokenOptions = return; } - tokens.forEach(token => { + for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) { + const token = tokens[tokenIndex]; if ( BitpaySupportedTokens[getCurrencyAbbreviation(token.address, chain)] ) { - return; + continue; } // remove bitpay supported tokens and currencies populateTokenInfo({ chain, @@ -61,7 +76,12 @@ export const startGetTokenOptions = tokenOptionsByAddress, tokenDataByAddress, }); - }); + if (tokenIndex > 0 && tokenIndex % TOKEN_OPTIONS_YIELD_EVERY === 0) { + await yieldToEventLoop(); + } + } + + await yieldToEventLoop(); } tokenManager.setTokenOptions({tokenOptionsByAddress, tokenDataByAddress}); logManager.info('successful [startGetTokenOptions]'); diff --git a/src/store/wallet/effects/rates/rates.ts b/src/store/wallet/effects/rates/rates.ts index fb389a023c..e4a7f229ca 100644 --- a/src/store/wallet/effects/rates/rates.ts +++ b/src/store/wallet/effects/rates/rates.ts @@ -109,31 +109,19 @@ const hasValidFiatRateSeriesInCache = (args: { coin: string; interval: FiatRateInterval; requireFresh?: boolean; -}): boolean => { - const hasValidSeries = hasValidSeriesForCoin({ + chain?: string; + tokenAddress?: string; +}): boolean => + hasValidSeriesForCoin({ cache: args.fiatRateSeriesCache, fiatCodeUpper: args.fiatCode, normalizedCoin: args.coin, intervals: [args.interval], + requireFresh: args.requireFresh, + freshnessDurationSeconds: HISTORIC_RATES_CACHE_DURATION, + chain: args.chain, + tokenAddress: args.tokenAddress, }); - if (!hasValidSeries) { - return false; - } - if (!args.requireFresh) { - return true; - } - - const cacheKey = getFiatRateSeriesCacheKey( - args.fiatCode, - args.coin, - args.interval, - ); - const fetchedOn = args.fiatRateSeriesCache?.[cacheKey]?.fetchedOn; - return ( - typeof fetchedOn === 'number' && - !isCacheKeyStale(fetchedOn, HISTORIC_RATES_CACHE_DURATION) - ); -}; const getFiatRateSeriesCadenceMs = ( points: FiatRatePoint[], @@ -228,14 +216,23 @@ const getFiatRateSeriesInFlightKey = (args: { fiatCode: string; interval: FiatRateInterval; coin?: string; + chain?: string; + tokenAddress?: string; }): string => { const fiatCodeUpper = (args.fiatCode || '').toUpperCase(); const normalizedCoin = normalizeFiatRateSeriesCoin(args.coin); - // Default v4 requests are shared across callers regardless of target coin. if (!normalizedCoin) { return `${fiatCodeUpper}:${args.interval}:default`; } - return `${fiatCodeUpper}:${normalizedCoin}:${args.interval}:coin`; + return getFiatRateSeriesCacheKey( + fiatCodeUpper, + normalizedCoin, + args.interval, + { + chain: args.chain, + tokenAddress: args.tokenAddress, + }, + ); }; const getAllowedCoinsSet = (allowedCoins?: string[]): Set | null => { @@ -555,6 +552,10 @@ export const fetchFiatRateSeriesInterval = fiatCode, coinForCacheCheck, interval, + { + chain, + tokenAddress, + }, ); const cached = fiatRateSeriesCache[cacheKey]; const normalizedCoinForCacheCheck = @@ -600,6 +601,8 @@ export const fetchFiatRateSeriesInterval = fiatCode, interval, coin: normalizedRequestedCoin || undefined, + chain, + tokenAddress, }); const allowedCoinsSet = getAllowedCoinsSet(allowedCoins); @@ -695,21 +698,44 @@ export const fetchFiatRateSeriesInterval = } updates = {}; - Object.keys(responseByCoin).forEach(seriesCoin => { - const normalizedSeriesCoin = normalizeFiatRateSeriesCoin(seriesCoin); - if (allowedCoinsSet && !allowedCoinsSet.has(normalizedSeriesCoin)) { - return; + if (normalizedRequestedCoin) { + const requestedSeriesPoints = + responseByCoin[normalizedRequestedCoin] ?? + (Array.isArray(data) ? data : undefined); + const deduped = sanitizeSortDedupePoints(requestedSeriesPoints); + if (deduped.length) { + updates[ + getFiatRateSeriesCacheKey( + fiatCode, + normalizedRequestedCoin, + interval, + { + chain, + tokenAddress, + }, + ) + ] = { + fetchedOn, + points: deduped, + }; } + } else { + Object.keys(responseByCoin).forEach(seriesCoin => { + const normalizedSeriesCoin = normalizeFiatRateSeriesCoin(seriesCoin); + if (allowedCoinsSet && !allowedCoinsSet.has(normalizedSeriesCoin)) { + return; + } - const deduped = sanitizeSortDedupePoints(responseByCoin[seriesCoin]); - if (!deduped.length) { - return; - } - updates[getFiatRateSeriesCacheKey(fiatCode, seriesCoin, interval)] = { - fetchedOn, - points: deduped, - }; - }); + const deduped = sanitizeSortDedupePoints(responseByCoin[seriesCoin]); + if (!deduped.length) { + return; + } + updates[getFiatRateSeriesCacheKey(fiatCode, seriesCoin, interval)] = { + fetchedOn, + points: deduped, + }; + }); + } updateCount = Object.keys(updates).length; if (updateCount) { @@ -740,6 +766,8 @@ export const fetchFiatRateSeriesInterval = coin: coinForCacheCheck, interval, requireFresh: true, + chain, + tokenAddress, }); if (hasFreshTargetCoinSeries) { return true; @@ -798,6 +826,8 @@ export const fetchFiatRateSeriesInterval = interval, // Do not let stale cached series block the coin-param refresh fallback. requireFresh: true, + chain, + tokenAddress, }); if (hasTargetCoinSeries) { return true; @@ -882,6 +912,8 @@ export const fetchFiatRateSeriesAllIntervals = coin: coinForCacheCheck, interval, requireFresh: true, + chain, + tokenAddress, }); }); @@ -913,9 +945,18 @@ export const refreshFiatRateSeries = currencyAbbreviation: string; interval: FiatRateInterval; spotRate?: number; + chain?: string; + tokenAddress?: string; }): Effect> => async (dispatch, getState) => { - const {fiatCode, currencyAbbreviation, interval, spotRate} = args; + const { + fiatCode, + currencyAbbreviation, + interval, + spotRate, + chain, + tokenAddress, + } = args; const { RATE: {fiatRateSeriesCache}, } = getState(); @@ -925,7 +966,10 @@ export const refreshFiatRateSeries = } const coin = normalizeFiatRateSeriesCoin(currencyAbbreviation); - const cacheKey = getFiatRateSeriesCacheKey(fiatCode, coin, interval); + const cacheKey = getFiatRateSeriesCacheKey(fiatCode, coin, interval, { + chain, + tokenAddress, + }); const cached = fiatRateSeriesCache[cacheKey]; if (!cached?.points?.length) { return false; diff --git a/src/store/wallet/effects/send/send.ts b/src/store/wallet/effects/send/send.ts index 7dd73e54aa..480cbe949d 100644 --- a/src/store/wallet/effects/send/send.ts +++ b/src/store/wallet/effects/send/send.ts @@ -59,10 +59,8 @@ import { } from '../../../../utils/helper-methods'; import {toFiat, checkEncryptPassword} from '../../utils/wallet'; import {startGetRates} from '../rates/rates'; -import { - startUpdateWalletStatus, - waitForTargetAmountAndUpdateWallet, -} from '../status/status'; +import {startUpdateWalletStatus} from '../status/status'; +import {waitForTargetAmountAndUpdateWallet} from '../status/waitForTargetAmountAndUpdateWallet'; import { CustomErrorMessage, ExcludedUtxosWarning, diff --git a/src/store/wallet/effects/status/status.ts b/src/store/wallet/effects/status/status.ts index c0321552a3..5290182f5d 100644 --- a/src/store/wallet/effects/status/status.ts +++ b/src/store/wallet/effects/status/status.ts @@ -5,7 +5,6 @@ import { WalletBalance, WalletStatus, Status, - Recipient, TransactionProposal, BulkStatus, CryptoBalance, @@ -22,14 +21,9 @@ import { successUpdateWalletStatus, updatePortfolioBalance, } from '../../wallet.actions'; -import {findWalletById, isCacheKeyStale, toFiat} from '../../utils/wallet'; +import {isCacheKeyStale, toFiat} from '../../utils/wallet'; import {BALANCE_CACHE_DURATION} from '../../../../constants/wallet'; -import {DeviceEventEmitter} from 'react-native'; -import {DeviceEmitterEvents} from '../../../../constants/device-emitter-events'; -import { - ProcessPendingTxps, - RemoveTxProposal, -} from '../transactions/transactions'; +import {ProcessPendingTxps} from '../transactions/transactions'; import {FormatAmount} from '../amount/amount'; import {BwcProvider} from '../../../../lib/bwc'; import {IsERCToken, IsUtxoChain} from '../../utils/currency'; @@ -40,108 +34,6 @@ import {createWalletAddress} from '../address/address'; import {detectAndCreateTokensForEachEvmWallet} from '../create/create'; import uniqBy from 'lodash.uniqby'; import {logManager} from '../../../../managers/LogManager'; - -/* - * post broadcasting of payment - * poll for updated balance -> update balance for: wallet, key, portfolio and local recipient wallet if applicable - * */ -export const waitForTargetAmountAndUpdateWallet = - ({ - key, - wallet, - targetAmount, - recipient, - }: { - key: Key; - wallet: Wallet; - targetAmount: number; - recipient?: Recipient; - }): Effect => - async (dispatch, getState) => { - try { - // Update history for showing confirming transactions - DeviceEventEmitter.emit(DeviceEmitterEvents.WALLET_LOAD_HISTORY); - - let retry = 0; - - // wait for expected balance - const interval = setInterval(() => { - logManager.debug('waiting for target balance', retry); - retry++; - - if (retry > 5) { - DeviceEventEmitter.emit(DeviceEmitterEvents.SET_REFRESHING, false); - clearInterval(interval); - return; - } - - const { - credentials: {token, multisigEthInfo}, - } = wallet; - - wallet.getStatus( - { - tokenAddress: token ? token.address : null, - multisigContractAddress: multisigEthInfo - ? multisigEthInfo.multisigContractAddress - : null, - network: wallet.network, - }, - async (err: any, status: Status) => { - if (err) { - const errStr = - err instanceof Error ? err.message : JSON.stringify(err); - logManager.error( - `error [waitForTargetAmountAndUpdateWallet]: ${errStr}`, - ); - } - const {totalAmount} = status?.balance; - // TODO ETH totalAmount !== targetAmount while the transaction is unconfirmed - // expected amount - update balance - if (totalAmount === targetAmount) { - clearInterval(interval); - dispatch(startUpdateWalletStatus({key, wallet, force: true})); - - // update recipient balance if local - if (recipient) { - const {walletId, keyId} = recipient; - if (walletId && keyId) { - const { - WALLET: {keys}, - } = getState(); - const recipientKey = keys[keyId]; - const recipientWallet = findWalletById(key.wallets, walletId); - if (recipientKey && recipientWallet) { - await dispatch( - startUpdateWalletStatus({ - key: recipientKey, - wallet: recipientWallet as Wallet, - force: true, - }), - ); - logManager.debug('updated recipient wallet'); - } - } - } - DeviceEventEmitter.emit(DeviceEmitterEvents.WALLET_LOAD_HISTORY); - DeviceEventEmitter.emit( - DeviceEmitterEvents.SET_REFRESHING, - false, - ); - await dispatch(updatePortfolioBalance()); - } - }, - ); - }, 5000); - } catch (err) { - const errstring = - err instanceof Error ? err.message : JSON.stringify(err); - logManager.error( - `Error WaitingForTargetAmountAndUpdateWallet: ${errstring}`, - ); - } - }; - export const startUpdateWalletStatus = ({key, wallet, force}: {key: Key; wallet: Wallet; force?: boolean}): Effect => async (dispatch, getState) => { diff --git a/src/store/wallet/effects/status/waitForTargetAmountAndUpdateWallet.ts b/src/store/wallet/effects/status/waitForTargetAmountAndUpdateWallet.ts new file mode 100644 index 0000000000..53ca1ececd --- /dev/null +++ b/src/store/wallet/effects/status/waitForTargetAmountAndUpdateWallet.ts @@ -0,0 +1,329 @@ +import {DeviceEventEmitter} from 'react-native'; + +import {Effect} from '../../../index'; +import {DeviceEmitterEvents} from '../../../../constants/device-emitter-events'; +import {logManager} from '../../../../managers/LogManager'; +import {formatUnknownError} from '../../../../utils/errors/formatUnknownError'; +import {getQuoteCurrency} from '../../../../utils/portfolio/assets'; +import {maybePopulatePortfolioForWallets} from '../../../portfolio'; +import {updatePortfolioBalance} from '../../wallet.actions'; +import {findWalletById} from '../../utils/wallet'; +import type {Key, Recipient, Status, Wallet} from '../../wallet.models'; +import {startUpdateWalletStatus} from './status'; + +const POLL_INTERVAL_MS = 5000; +const MAX_STATUS_REQUESTS = 5; +const MAX_POLLING_DURATION_MS = POLL_INTERVAL_MS * (MAX_STATUS_REQUESTS + 1); + +const maybePopulatePortfolioChartsForWalletIds = async ({ + dispatch, + getState, + walletIds, +}: { + dispatch: any; + getState: () => any; + walletIds: string[]; +}): Promise => { + const uniqueWalletIds = new Set( + (walletIds || []).filter((walletId): walletId is string => !!walletId), + ); + + if (!uniqueWalletIds.size) { + return; + } + + const state = getState(); + const keys = (state.WALLET?.keys || {}) as Record; + const wallets = (Object.values(keys) as Key[]) + .flatMap((walletKey: Key) => walletKey.wallets || []) + .filter((currentWallet: Wallet) => uniqueWalletIds.has(currentWallet.id)); + + if (!wallets.length) { + return; + } + + const quoteCurrency = getQuoteCurrency({ + portfolioQuoteCurrency: state.PORTFOLIO?.quoteCurrency, + defaultAltCurrencyIsoCode: state.APP?.defaultAltCurrency?.isoCode, + }).toUpperCase(); + + await dispatch( + maybePopulatePortfolioForWallets({ + wallets, + quoteCurrency, + }) as any, + ); +}; + +const getErrorMessage = (err: unknown): string => { + return formatUnknownError(err); +}; + +const getNextPollDelay = (requestStartedAt: number): number => { + const elapsedMs = Date.now() - requestStartedAt; + return Math.max(0, POLL_INTERVAL_MS - elapsedMs); +}; + +const getWalletStatus = async ( + wallet: Wallet, +): Promise<{err?: unknown; status?: Status}> => { + const { + credentials: {token, multisigEthInfo}, + } = wallet; + + return new Promise((resolve, reject) => { + try { + wallet.getStatus( + { + tokenAddress: token ? token.address : null, + multisigContractAddress: multisigEthInfo + ? multisigEthInfo.multisigContractAddress + : null, + network: wallet.network, + }, + (err: unknown, status: Status) => resolve({err, status}), + ); + } catch (err) { + reject(err); + } + }); +}; + +const getComparableTotalAmount = ({ + wallet, + status, +}: { + wallet: Wallet; + status?: Status; +}): number | undefined => { + const totalAmount = status?.balance?.totalAmount; + + if (typeof totalAmount !== 'number' || !Number.isFinite(totalAmount)) { + return undefined; + } + + if (['xrp', 'sol'].includes(wallet.chain)) { + const lockedConfirmedAmount = status?.balance?.lockedConfirmedAmount; + if ( + typeof lockedConfirmedAmount === 'number' && + Number.isFinite(lockedConfirmedAmount) + ) { + return totalAmount - lockedConfirmedAmount; + } + } + + return totalAmount; +}; + +const hasReachedTargetAmount = ({ + wallet, + status, + targetAmount, + initialBalanceSat, +}: { + wallet: Wallet; + status?: Status; + targetAmount: number; + initialBalanceSat: number; +}): boolean => { + const comparableTotalAmount = getComparableTotalAmount({wallet, status}); + + if (typeof comparableTotalAmount !== 'number') { + return false; + } + + return targetAmount <= initialBalanceSat + ? comparableTotalAmount <= targetAmount + : comparableTotalAmount >= targetAmount; +}; + +const refreshWalletsAfterTargetAmount = async ({ + dispatch, + getState, + key, + wallet, + recipient, +}: { + dispatch: any; + getState: () => any; + key: Key; + wallet: Wallet; + recipient?: Recipient; +}): Promise => { + const updatedWalletIds = new Set([wallet.id]); + + await dispatch(startUpdateWalletStatus({key, wallet, force: true})); + + if (recipient) { + const {walletId, keyId} = recipient; + if (walletId && keyId) { + const { + WALLET: {keys}, + } = getState(); + const recipientKey = keys[keyId]; + const recipientWallet = recipientKey + ? findWalletById(recipientKey.wallets, walletId) + : undefined; + + if (recipientKey && recipientWallet) { + await dispatch( + startUpdateWalletStatus({ + key: recipientKey, + wallet: recipientWallet as Wallet, + force: true, + }), + ); + updatedWalletIds.add(walletId); + } + } + } + + DeviceEventEmitter.emit(DeviceEmitterEvents.WALLET_LOAD_HISTORY); + await dispatch(updatePortfolioBalance()); + await maybePopulatePortfolioChartsForWalletIds({ + dispatch, + getState, + walletIds: Array.from(updatedWalletIds), + }); +}; + +/* + * post broadcasting of payment + * poll for updated balance -> update balance for: wallet, key, portfolio and local recipient wallet if applicable + * + * Kept in its own module so the post-send chart refresh can depend on the + * portfolio effects without reintroducing the status <-> portfolio require cycle. + */ +export const waitForTargetAmountAndUpdateWallet = + ({ + key, + wallet, + targetAmount, + recipient, + }: { + key: Key; + wallet: Wallet; + targetAmount: number; + recipient?: Recipient; + }): Effect => + async (dispatch, getState) => { + let timeout: ReturnType | undefined; + let deadlineTimeout: ReturnType | undefined; + let isPollingComplete = false; + const initialBalanceSat = wallet.balance.sat; + + const stopPolling = () => { + if (isPollingComplete) { + return; + } + + isPollingComplete = true; + + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + + if (deadlineTimeout) { + clearTimeout(deadlineTimeout); + deadlineTimeout = undefined; + } + + DeviceEventEmitter.emit(DeviceEmitterEvents.SET_REFRESHING, false); + }; + + try { + // Update history for showing confirming transactions + DeviceEventEmitter.emit(DeviceEmitterEvents.WALLET_LOAD_HISTORY); + + let requestCount = 0; + deadlineTimeout = setTimeout(() => { + stopPolling(); + }, MAX_POLLING_DURATION_MS); + + const scheduleNextPoll = (delayMs = POLL_INTERVAL_MS) => { + if (isPollingComplete) { + return; + } + + timeout = setTimeout(async () => { + if (isPollingComplete) { + return; + } + + requestCount += 1; + + if (requestCount > MAX_STATUS_REQUESTS) { + stopPolling(); + return; + } + + const requestStartedAt = Date.now(); + + try { + const {err, status} = await getWalletStatus(wallet); + + if (isPollingComplete) { + return; + } + + if (err) { + logManager.error( + `error [waitForTargetAmountAndUpdateWallet]: ${getErrorMessage( + err, + )}`, + ); + } + + if ( + !hasReachedTargetAmount({ + wallet, + status, + targetAmount, + initialBalanceSat, + }) + ) { + scheduleNextPoll(getNextPollDelay(requestStartedAt)); + return; + } + + try { + await refreshWalletsAfterTargetAmount({ + dispatch, + getState, + key, + wallet, + recipient, + }); + } catch (refreshErr) { + logManager.error( + `error [waitForTargetAmountAndUpdateWallet]: ${getErrorMessage( + refreshErr, + )}`, + ); + } finally { + stopPolling(); + } + + return; + } catch (err) { + logManager.error( + `error [waitForTargetAmountAndUpdateWallet]: ${getErrorMessage( + err, + )}`, + ); + } + + scheduleNextPoll(getNextPollDelay(requestStartedAt)); + }, delayMs); + }; + + scheduleNextPoll(); + } catch (err) { + const errstring = getErrorMessage(err); + logManager.error( + `Error WaitingForTargetAmountAndUpdateWallet: ${errstring}`, + ); + stopPolling(); + } + }; diff --git a/src/utils/abort.ts b/src/utils/abort.ts new file mode 100644 index 0000000000..1559dd0032 --- /dev/null +++ b/src/utils/abort.ts @@ -0,0 +1,29 @@ +const ABORT_ERROR_NAME = 'AbortError'; +const DEFAULT_ABORT_MESSAGE = 'The operation was aborted.'; + +export const createAbortError = (message = DEFAULT_ABORT_MESSAGE): Error => { + const error = new Error(message); + error.name = ABORT_ERROR_NAME; + return error; +}; + +export const isAbortError = (error: unknown): boolean => { + return error instanceof Error && error.name === ABORT_ERROR_NAME; +}; + +export const throwIfAbortSignalAborted = (signal?: AbortSignal): void => { + if (!signal?.aborted) { + return; + } + + const reason = (signal as AbortSignal & {reason?: unknown}).reason; + if (reason instanceof Error) { + throw reason; + } + + if (typeof reason === 'string' && reason) { + throw createAbortError(reason); + } + + throw createAbortError(); +}; diff --git a/src/utils/errors/formatUnknownError.ts b/src/utils/errors/formatUnknownError.ts new file mode 100644 index 0000000000..a285a61179 --- /dev/null +++ b/src/utils/errors/formatUnknownError.ts @@ -0,0 +1,66 @@ +const DEFAULT_MAX_LENGTH = 2000; + +const truncate = (value: string, maxLength: number): string => { + if (value.length <= maxLength) { + return value; + } + + if (maxLength <= 0) { + return ''; + } + + if (maxLength === 1) { + return '…'; + } + + return `${value.slice(0, maxLength - 1)}…`; +}; + +const safeStringify = (value: unknown): string | undefined => { + const seen = new WeakSet(); + + try { + const result = JSON.stringify(value, (_key, currentValue: unknown) => { + if (typeof currentValue === 'bigint') { + return `${currentValue.toString()}n`; + } + + if (currentValue && typeof currentValue === 'object') { + if (seen.has(currentValue)) { + return '[Circular]'; + } + + seen.add(currentValue); + } + + return currentValue; + }); + + return typeof result === 'string' ? result : undefined; + } catch { + return undefined; + } +}; + +export const formatUnknownError = ( + err: unknown, + opts?: {maxLength?: number}, +): string => { + const maxLength = + typeof opts?.maxLength === 'number' && Number.isFinite(opts.maxLength) + ? Math.max(0, Math.floor(opts.maxLength)) + : DEFAULT_MAX_LENGTH; + + try { + const message = + err instanceof Error + ? err.message || err.name + : typeof err === 'string' + ? err + : safeStringify(err) ?? String(err); + + return truncate(message || 'Unknown error', maxLength); + } catch { + return truncate('Unknown error', maxLength); + } +}; diff --git a/src/utils/fiatAmountText.ts b/src/utils/fiatAmountText.ts new file mode 100644 index 0000000000..499ac633c1 --- /dev/null +++ b/src/utils/fiatAmountText.ts @@ -0,0 +1,8 @@ +export const DEFAULT_COMPACT_FIAT_TEXT_THRESHOLD = 11; + +export const shouldUseCompactFiatAmountText = ( + formattedFiatAmount?: string, + threshold = DEFAULT_COMPACT_FIAT_TEXT_THRESHOLD, +) => { + return (formattedFiatAmount || '').length > threshold; +}; diff --git a/src/utils/fiatTimeframes.ts b/src/utils/fiatTimeframes.ts new file mode 100644 index 0000000000..b788f53360 --- /dev/null +++ b/src/utils/fiatTimeframes.ts @@ -0,0 +1 @@ +export * from './portfolio/core/fiatTimeframes'; diff --git a/src/utils/portfolio/allocation.ts b/src/utils/portfolio/allocation.ts index 04dc6b0030..38e9f8e04b 100644 --- a/src/utils/portfolio/allocation.ts +++ b/src/utils/portfolio/allocation.ts @@ -3,11 +3,8 @@ import {formatCurrencyAbbreviation, formatFiatAmount} from '../helper-methods'; import type {Key, Wallet} from '../../store/wallet/wallet.models'; import type {HomeCarouselConfig} from '../../store/app/app.models'; import {Slate, SlateDark} from '../../styles/colors'; -import { - BitpaySupportedCoins, - BitpaySupportedTokens, -} from '../../constants/currencies'; import {getVisibleWalletsFromKeys} from './assets'; +import {getAssetTheme} from './assetTheme'; type AllocationAsset = { assetKey: string; @@ -72,28 +69,16 @@ export type AllocationRowItem = { progress: number; }; -const tokenThemeByCoin: {[key in string]: string} = Object.values( - BitpaySupportedTokens, -).reduce((acc, token) => { - const coinKey = (token.coin || '').toLowerCase(); - const color = token.theme?.coinColor; - if (coinKey && color && !acc[coinKey]) { - acc[coinKey] = color; - } - return acc; -}, {} as {[key in string]: string}); - const getAssetColor = ( currencyAbbreviation: string, chain?: string, + tokenAddress?: string, ): {light: string; dark: string} => { - const coinKey = (currencyAbbreviation || '').toLowerCase(); - const chainKey = (chain || '').toLowerCase(); - - const themeColor = - BitpaySupportedCoins[coinKey]?.theme?.coinColor || - BitpaySupportedCoins[chainKey]?.theme?.coinColor || - tokenThemeByCoin[coinKey]; + const themeColor = getAssetTheme({ + currencyAbbreviation, + chain, + tokenAddress, + })?.coinColor; return themeColor ? {light: themeColor, dark: themeColor} @@ -146,7 +131,7 @@ export const buildAllocationDataFromWalletRows = ( assetKey, currencyAbbreviation: (w.currencyAbbreviation || '').toLowerCase(), chain: (w.chain || '').toLowerCase(), - tokenAddress: w.tokenAddress?.toLowerCase(), + tokenAddress: w.tokenAddress, name: w.currencyName || w.currencyAbbreviation || '', fiatValue: fiat, }); @@ -163,7 +148,7 @@ export const buildAllocationDataFromWalletRows = ( return { ...a, percent, - color: getAssetColor(a.currencyAbbreviation, a.chain), + color: getAssetColor(a.currencyAbbreviation, a.chain, a.tokenAddress), }; }); diff --git a/src/utils/portfolio/assetTheme.ts b/src/utils/portfolio/assetTheme.ts new file mode 100644 index 0000000000..99bd489269 --- /dev/null +++ b/src/utils/portfolio/assetTheme.ts @@ -0,0 +1,67 @@ +import { + BitpaySupportedCoins, + BitpaySupportedTokens, + type CurrencyOpts, +} from '../../constants/currencies'; +import {addTokenChainSuffix} from '../helper-methods'; + +export type AssetTheme = NonNullable; + +export type AssetThemeArgs = { + currencyAbbreviation?: string; + chain?: string; + tokenAddress?: string; +}; + +const normalize = (value: string | undefined): string => + (value || '').toLowerCase(); + +const solidTheme = (color: string): AssetTheme => ({ + coinColor: color, + backgroundColor: color, + gradientBackgroundColor: color, +}); + +const tokenThemeByCoin: {[key in string]: AssetTheme} = Object.values( + BitpaySupportedTokens, +).reduce((acc, token) => { + const coinKey = normalize(token.coin); + const color = token.theme?.coinColor; + if (coinKey && color && !acc[coinKey]) { + acc[coinKey] = solidTheme(color); + } + return acc; +}, {} as {[key in string]: AssetTheme}); + +export const getAssetTheme = (args: AssetThemeArgs): AssetTheme | undefined => { + const chain = normalize(args.chain); + const currencyAbbreviation = normalize(args.currencyAbbreviation); + const tokenAddress = + typeof args.tokenAddress === 'string' ? args.tokenAddress.trim() : ''; + const assetTheme = currencyAbbreviation + ? BitpaySupportedCoins[currencyAbbreviation]?.theme + : undefined; + + if (tokenAddress && chain) { + const tokenKey = addTokenChainSuffix(tokenAddress, chain); + const strictTheme = BitpaySupportedTokens[tokenKey]?.theme; + if (strictTheme) { + return strictTheme; + } + } + + const fallbackTokenTheme = tokenThemeByCoin[currencyAbbreviation]; + if (fallbackTokenTheme) { + return fallbackTokenTheme; + } + + if (assetTheme) { + return assetTheme; + } + + if (!chain) { + return undefined; + } + + return BitpaySupportedCoins[chain]?.theme; +}; diff --git a/src/utils/portfolio/assets.ts b/src/utils/portfolio/assets.ts index c22605dfbe..e2c53c4cbc 100644 --- a/src/utils/portfolio/assets.ts +++ b/src/utils/portfolio/assets.ts @@ -1,7 +1,8 @@ import {Network} from '../../constants'; import type {HomeCarouselConfig} from '../../store/app/app.models'; -import type {BalanceSnapshot} from '../../store/portfolio/portfolio.models'; import type { + BalanceSnapshot, + BalanceSnapshotsByWalletId, PortfolioPopulateStatus, SnapshotBalanceMismatch, WalletPopulateState, @@ -11,8 +12,12 @@ import type { FiatRateSeriesCache, Rates, } from '../../store/rate/rate.models'; -import {hasValidSeriesForCoin} from '../../store/rate/rate.models'; +import { + getFiatRateSeriesCacheKey, + hasValidSeriesForCoin, +} from '../../store/rate/rate.models'; import type {Key, Wallet} from '../../store/wallet/wallet.models'; +import {IsSVMChain} from '../../store/wallet/utils/currency'; import type {SupportedCurrencyOption} from '../../constants/SupportedCurrencyOptions'; import { BitpaySupportedCoins, @@ -33,6 +38,7 @@ import { getRateByCurrencyName, unitStringToAtomicBigInt, } from '../helper-methods'; +import {throwIfAbortSignalAborted} from '../abort'; // PnL engine (lifted from the web harness). Keep these imports path-stable so the // engine code stays easily portable between RN + web. @@ -40,6 +46,7 @@ import { buildPnlAnalysisSeries, type WalletForAnalysis, } from './core/pnl/analysis'; +import {getFiatRateSeriesAssetKey} from './core/fiatRateSeries'; import {normalizeFiatRateSeriesCoin as normalizeCoinForPnlRates} from './core/pnl/rates'; import type {BalanceSnapshotStored} from './core/pnl/types'; import {formatBigIntDecimal} from './core/format'; @@ -173,6 +180,190 @@ const toNumber = (v: unknown): number => { return Number.isFinite(n) ? n : 0; }; +const toStringOrEmpty = (value: unknown): string => + value === null || value === undefined ? '' : String(value); + +const toOptionalString = (value: unknown): string | undefined => { + const normalized = toStringOrEmpty(value); + return normalized === '' ? undefined : normalized; +}; + +type WalletWithRuntimeName = Wallet & { + name?: unknown; +}; + +type WalletWithTokenCredentials = Wallet & { + credentials?: { + token?: { + decimals?: unknown; + }; + }; +}; + +export const getPortfolioWalletId = (wallet: Wallet | undefined): string => { + return toStringOrEmpty(wallet?.id); +}; + +export const getPortfolioWalletCurrencyAbbreviation = ( + wallet: Wallet | undefined, +): string => { + return toStringOrEmpty(wallet?.currencyAbbreviation); +}; + +export const getPortfolioWalletCurrencyAbbreviationLower = ( + wallet: Wallet | undefined, +): string => { + return getPortfolioWalletCurrencyAbbreviation(wallet).toLowerCase(); +}; + +export const getPortfolioWalletChain = ( + wallet: Wallet | undefined, + fallback = '', +): string => { + return toStringOrEmpty(wallet?.chain || fallback); +}; + +export const getPortfolioWalletChainLower = ( + wallet: Wallet | undefined, + fallback = '', +): string => { + return getPortfolioWalletChain(wallet, fallback).toLowerCase(); +}; + +export const getPortfolioWalletTokenAddress = ( + wallet: Wallet | undefined, +): string | undefined => { + return toOptionalString(wallet?.tokenAddress); +}; + +export const getPortfolioWalletTokenAddressNormalized = ( + wallet: Wallet | undefined, +): string | undefined => { + const tokenAddress = getPortfolioWalletTokenAddress(wallet); + if (!tokenAddress) { + return undefined; + } + + return IsSVMChain(getPortfolioWalletChain(wallet)) + ? tokenAddress + : tokenAddress.toLowerCase(); +}; + +export const isPortfolioWalletOnMainnet = ( + wallet: Wallet | undefined, +): boolean => { + return wallet?.network === Network.mainnet; +}; + +export const getPortfolioWalletSnapshots = ( + snapshotsByWalletId: BalanceSnapshotsByWalletId | undefined, + walletId: string, +): BalanceSnapshot[] => { + const snapshots = snapshotsByWalletId?.[walletId]; + return Array.isArray(snapshots) ? snapshots : []; +}; + +const getPortfolioWalletDisplayName = ( + wallet: Wallet | undefined, + fallback = '', +): string => { + const walletName = toOptionalString(wallet?.walletName); + const runtimeName = toOptionalString( + (wallet as WalletWithRuntimeName | undefined)?.name, + ); + return walletName || runtimeName || fallback; +}; + +const getPortfolioWalletTokenDecimals = ( + wallet: Wallet | undefined, +): number | undefined => { + const decimals = (wallet as WalletWithTokenCredentials | undefined) + ?.credentials?.token?.decimals; + return typeof decimals === 'number' && Number.isFinite(decimals) + ? decimals + : undefined; +}; + +const getPortfolioSnapshotId = ( + snapshot: BalanceSnapshot | undefined, +): string => { + return toStringOrEmpty(snapshot?.id); +}; + +const getPortfolioSnapshotChain = ( + snapshot: BalanceSnapshot | undefined, + fallback = '', +): string => { + return toStringOrEmpty(snapshot?.chain || fallback); +}; + +const getPortfolioSnapshotCoin = ( + snapshot: BalanceSnapshot | undefined, + fallback = '', +): string => { + return toStringOrEmpty(snapshot?.coin || fallback); +}; + +const getPortfolioSnapshotNetwork = ( + snapshot: BalanceSnapshot | undefined, + fallback = 'livenet', +): string => { + return toStringOrEmpty(snapshot?.network || fallback); +}; + +const getPortfolioSnapshotTimestampMs = ( + snapshot: BalanceSnapshot | undefined, +): number => { + return toNumber(snapshot?.timestamp); +}; + +const getPortfolioSnapshotEventType = ( + snapshot: BalanceSnapshot | undefined, +): BalanceSnapshotStored['eventType'] => { + return snapshot?.eventType === 'daily' ? 'daily' : 'tx'; +}; + +const getPortfolioSnapshotCryptoBalance = ( + snapshot: BalanceSnapshot | undefined, +): string => { + return toStringOrEmpty(snapshot?.cryptoBalance || '0'); +}; + +const getPortfolioSnapshotQuoteCurrency = ( + snapshot: BalanceSnapshot | undefined, + fallback = '', +): string => { + return toStringOrEmpty(snapshot?.quoteCurrency || fallback).toUpperCase(); +}; + +const getPortfolioSnapshotRemainingCostBasisFiat = ( + snapshot: BalanceSnapshot | undefined, +): number => { + return toNumber(snapshot?.remainingCostBasisFiat); +}; + +const getPortfolioSnapshotCostBasisRateFiat = ( + snapshot: BalanceSnapshot | undefined, +): number => { + return typeof snapshot?.costBasisRateFiat === 'number' + ? snapshot.costBasisRateFiat + : 0; +}; + +const getPortfolioSnapshotCreatedAt = ( + snapshot: BalanceSnapshot | undefined, +): number | undefined => { + return typeof snapshot?.createdAt === 'number' + ? snapshot.createdAt + : undefined; +}; + +const getPortfolioSnapshotTxIds = ( + snapshot: BalanceSnapshot | undefined, +): string[] | undefined => { + return Array.isArray(snapshot?.txIds) ? snapshot.txIds : undefined; +}; + const MS_PER_DAY = 24 * 60 * 60 * 1000; const getPreferredIntervalsForTimestamp = (args: { @@ -199,6 +390,7 @@ const getRateAtTimestampFromCache = (args: { timestampMs: number; nowMs: number; method?: 'nearest' | 'linear'; + onHistoricalRateDependency?: (cacheKey: string) => void; }): number | undefined => { const preferredIntervals = getPreferredIntervalsForTimestamp({ timestampMs: args.timestampMs, @@ -206,13 +398,14 @@ const getRateAtTimestampFromCache = (args: { }); const seen = new Set(); - const intervals: FiatRateInterval[] = [ + const candidateIntervals: FiatRateInterval[] = [ ...preferredIntervals, '1D', '1W', '1M', 'ALL', - ].filter(interval => { + ]; + const intervals = candidateIntervals.filter(interval => { if (seen.has(interval)) { return false; } @@ -230,6 +423,13 @@ const getRateAtTimestampFromCache = (args: { method: args.method || 'nearest', }); if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) { + args.onHistoricalRateDependency?.( + getFiatRateSeriesCacheKey( + args.fiatCode, + normalizeCoinForPnlRates(args.currencyAbbreviation), + interval, + ), + ); return rate; } } @@ -244,6 +444,7 @@ const convertAmountBetweenQuotesViaBtc = (args: { timestampMs: number; fiatRateSeriesCache: FiatRateSeriesCache | undefined; nowMs: number; + onHistoricalRateDependency?: (cacheKey: string) => void; }): number | undefined => { const amount = toNumber(args.amount); if (!(amount > 0)) { @@ -266,6 +467,7 @@ const convertAmountBetweenQuotesViaBtc = (args: { timestampMs: args.timestampMs, nowMs: args.nowMs, method: 'nearest', + onHistoricalRateDependency: args.onHistoricalRateDependency, }); const targetBtcRate = getRateAtTimestampFromCache({ fiatRateSeriesCache: args.fiatRateSeriesCache, @@ -274,6 +476,7 @@ const convertAmountBetweenQuotesViaBtc = (args: { timestampMs: args.timestampMs, nowMs: args.nowMs, method: 'nearest', + onHistoricalRateDependency: args.onHistoricalRateDependency, }); if ( @@ -473,13 +676,12 @@ export const buildWalletIdsByAssetGroupKey = ( ): Record => { const map: Record = {}; for (const w of wallets || []) { - const id = (w as any)?.id as string | undefined; - - if ((w as any)?.network !== Network.mainnet) { + const id = getPortfolioWalletId(w); + if (!isPortfolioWalletOnMainnet(w)) { continue; } - const groupKey = ((w as any)?.currencyAbbreviation || '').toLowerCase(); + const groupKey = getPortfolioWalletCurrencyAbbreviationLower(w); if (!id || !groupKey) { continue; } @@ -601,7 +803,7 @@ const ensureSortedSnapshots = ( return arr; }; -const mapSnapshotsToStored = (args: { +type MapSnapshotsToStoredArgs = { snapshots: BalanceSnapshot[]; wallet: Wallet; walletId: string; @@ -613,82 +815,176 @@ const mapSnapshotsToStored = (args: { fiatRateSeriesCache?: FiatRateSeriesCache; nowMs?: number; fallbackAssetIdToWalletIdentity: boolean; -}): BalanceSnapshotStored[] => { - const tokenAddress = (args.wallet as any)?.tokenAddress as string | undefined; - const tokenAddressLower = tokenAddress - ? tokenAddress.toLowerCase() - : undefined; + onHistoricalRateDependency?: (cacheKey: string) => void; +}; + +const mapSnapshotToStored = (args: { + snapshot: BalanceSnapshot; + wallet: Wallet; + walletId: string; + unitDecimals: number; + fallbackChain: string; + fallbackCoin: string; + fallbackQuoteCurrency: string; + targetQuoteCurrency?: string; + fiatRateSeriesCache?: FiatRateSeriesCache; + nowMs: number; + fallbackAssetIdToWalletIdentity: boolean; + onHistoricalRateDependency?: (cacheKey: string) => void; +}): BalanceSnapshotStored => { + const s = args.snapshot; + const tokenAddressNormalized = getPortfolioWalletTokenAddressNormalized( + args.wallet, + ); + const snapshotChain = getPortfolioSnapshotChain( + s, + getPortfolioWalletChain(args.wallet), + ).toLowerCase(); + const snapshotCoin = getPortfolioSnapshotCoin( + s, + getPortfolioWalletCurrencyAbbreviation(args.wallet), + ).toLowerCase(); + const chainForFields = snapshotChain || args.fallbackChain; + const coinForFields = snapshotCoin || args.fallbackCoin; + const assetChain = args.fallbackAssetIdToWalletIdentity + ? chainForFields + : snapshotChain; + const assetCoin = args.fallbackAssetIdToWalletIdentity + ? coinForFields + : snapshotCoin; + const assetId = tokenAddressNormalized + ? `${assetChain}:${assetCoin}:${tokenAddressNormalized}` + : `${assetChain}:${assetCoin}`; + const snapshotQuoteCurrency = getPortfolioSnapshotQuoteCurrency( + s, + args.fallbackQuoteCurrency, + ); + const targetQuoteCurrency = ( + args.targetQuoteCurrency || args.fallbackQuoteCurrency + ).toUpperCase(); + const remainingCostBasisFiatRaw = + getPortfolioSnapshotRemainingCostBasisFiat(s); + + let markRate = getPortfolioSnapshotCostBasisRateFiat(s); + let remainingCostBasisFiat = remainingCostBasisFiatRaw; + let storedQuoteCurrency = targetQuoteCurrency || snapshotQuoteCurrency; + + if (snapshotQuoteCurrency !== targetQuoteCurrency) { + const convertedMarkRate = + markRate > 0 + ? convertAmountBetweenQuotesViaBtc({ + amount: markRate, + sourceQuoteCurrency: snapshotQuoteCurrency, + targetQuoteCurrency, + timestampMs: getPortfolioSnapshotTimestampMs(s), + fiatRateSeriesCache: args.fiatRateSeriesCache, + nowMs: args.nowMs, + onHistoricalRateDependency: args.onHistoricalRateDependency, + }) + : markRate; + const convertedRemainingCostBasisFiat = convertAmountBetweenQuotesViaBtc({ + amount: remainingCostBasisFiatRaw, + sourceQuoteCurrency: snapshotQuoteCurrency, + targetQuoteCurrency, + timestampMs: getPortfolioSnapshotTimestampMs(s), + fiatRateSeriesCache: args.fiatRateSeriesCache, + nowMs: args.nowMs, + onHistoricalRateDependency: args.onHistoricalRateDependency, + }); + + const canUseConvertedMarkRate = + markRate <= 0 || + (typeof convertedMarkRate === 'number' && + Number.isFinite(convertedMarkRate) && + convertedMarkRate > 0); + const canUseConvertedCostBasis = + typeof convertedRemainingCostBasisFiat === 'number' && + Number.isFinite(convertedRemainingCostBasisFiat); + + if (canUseConvertedMarkRate && canUseConvertedCostBasis) { + if (markRate > 0 && typeof convertedMarkRate === 'number') { + markRate = convertedMarkRate; + } + remainingCostBasisFiat = convertedRemainingCostBasisFiat; + storedQuoteCurrency = targetQuoteCurrency; + } else { + storedQuoteCurrency = snapshotQuoteCurrency; + } + } + + return { + id: getPortfolioSnapshotId(s), + walletId: args.walletId, + chain: chainForFields, + coin: coinForFields, + network: getPortfolioSnapshotNetwork(s), + assetId, + timestamp: getPortfolioSnapshotTimestampMs(s), + eventType: getPortfolioSnapshotEventType(s), + cryptoBalance: unitStringToAtomicBigInt( + getPortfolioSnapshotCryptoBalance(s), + args.unitDecimals, + ).toString(), + remainingCostBasisFiat, + quoteCurrency: storedQuoteCurrency, + markRate, + createdAt: getPortfolioSnapshotCreatedAt(s), + txIds: getPortfolioSnapshotTxIds(s), + }; +}; + +const mapSnapshotsToStored = ( + args: MapSnapshotsToStoredArgs, +): BalanceSnapshotStored[] => { const nowMs = typeof args.nowMs === 'number' ? args.nowMs : Date.now(); - return args.snapshots.map(s => { - const snapshotChain = String( - (s as any)?.chain || (args.wallet as any)?.chain || '', - ).toLowerCase(); - const snapshotCoin = String( - (s as any)?.coin || (args.wallet as any)?.currencyAbbreviation || '', - ).toLowerCase(); - const chainForFields = snapshotChain || args.fallbackChain; - const coinForFields = snapshotCoin || args.fallbackCoin; - const assetChain = args.fallbackAssetIdToWalletIdentity - ? chainForFields - : snapshotChain; - const assetCoin = args.fallbackAssetIdToWalletIdentity - ? coinForFields - : snapshotCoin; - const assetId = tokenAddressLower - ? `${assetChain}:${assetCoin}:${tokenAddressLower}` - : `${assetChain}:${assetCoin}`; - const snapshotQuoteCurrency = String( - (s as any)?.quoteCurrency || args.fallbackQuoteCurrency, - ).toUpperCase(); - const targetQuoteCurrency = ( - args.targetQuoteCurrency || args.fallbackQuoteCurrency - ).toUpperCase(); - - let markRate = - typeof (s as any)?.costBasisRateFiat === 'number' - ? (s as any).costBasisRateFiat - : 0; - - if (markRate > 0 && snapshotQuoteCurrency !== targetQuoteCurrency) { - const convertedMarkRate = convertAmountBetweenQuotesViaBtc({ - amount: markRate, - sourceQuoteCurrency: snapshotQuoteCurrency, - targetQuoteCurrency, - timestampMs: Number((s as any)?.timestamp || 0), - fiatRateSeriesCache: args.fiatRateSeriesCache, + return args.snapshots.map(snapshot => + mapSnapshotToStored({ + ...args, + snapshot, + nowMs, + }), + ); +}; + +const yieldToEventLoop = async (): Promise => + new Promise(resolve => setTimeout(resolve, 0)); + +const mapSnapshotsToStoredAsync = async ( + args: MapSnapshotsToStoredArgs, + asyncOpts?: { + signal?: AbortSignal; + yieldEverySnapshots?: number; + yieldControl?: () => Promise; + }, +): Promise => { + const nowMs = typeof args.nowMs === 'number' ? args.nowMs : Date.now(); + const yieldEverySnapshots = Math.max( + 1, + Math.floor(asyncOpts?.yieldEverySnapshots ?? 200), + ); + const yieldControl = asyncOpts?.yieldControl || yieldToEventLoop; + const out: BalanceSnapshotStored[] = []; + + throwIfAbortSignalAborted(asyncOpts?.signal); + + for (let i = 0; i < args.snapshots.length; i++) { + throwIfAbortSignalAborted(asyncOpts?.signal); + out.push( + mapSnapshotToStored({ + ...args, + snapshot: args.snapshots[i], nowMs, - }); + }), + ); - markRate = - typeof convertedMarkRate === 'number' && convertedMarkRate > 0 - ? convertedMarkRate - : 0; + if ((i + 1) % yieldEverySnapshots === 0) { + await yieldControl(); + throwIfAbortSignalAborted(asyncOpts?.signal); } + } - return { - id: String((s as any)?.id || ''), - walletId: args.walletId, - chain: chainForFields, - coin: coinForFields, - network: String((s as any)?.network || 'livenet'), - assetId, - timestamp: Number((s as any)?.timestamp || 0), - eventType: ((s as any)?.eventType || 'tx') as any, - cryptoBalance: unitStringToAtomicBigInt( - String((s as any)?.cryptoBalance || '0'), - args.unitDecimals, - ).toString(), - remainingCostBasisFiat: Number((s as any)?.remainingCostBasisFiat || 0), - quoteCurrency: targetQuoteCurrency || snapshotQuoteCurrency, - markRate, - createdAt: - typeof (s as any)?.createdAt === 'number' - ? (s as any).createdAt - : undefined, - txIds: Array.isArray((s as any)?.txIds) ? (s as any).txIds : undefined, - }; - }); + return out; }; export const getLatestSnapshot = ( @@ -704,12 +1000,12 @@ const getWalletUnitInfo = ( unitDecimals: number; unitToSatoshi: number; } => { - const chain = ((wallet as any)?.chain || '').toLowerCase(); - const tokenAddress = (wallet as any)?.tokenAddress as string | undefined; + const chain = getPortfolioWalletChainLower(wallet); + const tokenAddress = getPortfolioWalletTokenAddress(wallet); const inferUnitToSatoshiFromLiveBalance = (): number | undefined => { - const sat = toNumber((wallet as any)?.balance?.sat); - const cryptoStr = (wallet as any)?.balance?.crypto; + const sat = toNumber(wallet.balance?.sat); + const cryptoStr = wallet.balance?.crypto; const crypto = toNumber( typeof cryptoStr === 'string' ? cryptoStr.replace(/,/g, '') : cryptoStr, ); @@ -734,8 +1030,7 @@ const getWalletUnitInfo = ( if (tokenAddress) { const currencyName = getCurrencyAbbreviation(tokenAddress, chain); - const credentialsTokenDecimals = (wallet as any)?.credentials?.token - ?.decimals; + const credentialsTokenDecimals = getPortfolioWalletTokenDecimals(wallet); const supportedUnitDecimals = BitpaySupportedTokens[currencyName]?.unitInfo?.unitDecimals; @@ -798,7 +1093,7 @@ const getWalletAtomicBalanceFromCryptoBalance = (args: { wallet: Wallet; unitDecimals: number; }): bigint => { - const crypto = (args.wallet as any)?.balance?.crypto; + const crypto = args.wallet.balance?.crypto; const unitString = typeof crypto === 'string' ? crypto.replace(/,/g, '') : '0'; return unitStringToAtomicBigInt(unitString, args.unitDecimals); @@ -808,11 +1103,11 @@ export const getWalletLiveAtomicBalance = (args: { wallet: Wallet; unitDecimals: number; }): bigint => { - const chain = String((args.wallet as any)?.chain || '').toLowerCase(); - const sat = (args.wallet as any)?.balance?.sat; - const satConfirmedLocked = (args.wallet as any)?.balance?.satConfirmedLocked; - const satConfirmed = (args.wallet as any)?.balance?.satConfirmed; - const satPending = (args.wallet as any)?.balance?.satPending; + const chain = getPortfolioWalletChainLower(args.wallet); + const sat = args.wallet.balance?.sat; + const satConfirmedLocked = args.wallet.balance?.satConfirmedLocked; + const satConfirmed = args.wallet.balance?.satConfirmed; + const satPending = args.wallet.balance?.satPending; if ( typeof sat === 'number' && @@ -988,7 +1283,7 @@ export const getWalletIdsToPopulateFromSnapshots = (args: { const buildPortfolioSnapshotContext = (args: { wallets: Wallet[]; - snapshotsByWalletId: {[walletId: string]: BalanceSnapshot[] | undefined}; + snapshotsByWalletId: BalanceSnapshotsByWalletId; preferredQuoteCurrency: string; }): { walletById: Map; @@ -1025,26 +1320,6 @@ const formatDeltaPercent = (ratio: number): string => { return `${prefix}${abs.toFixed(1)}%`; }; -const getCurrencySymbol = (isoCode: string): string | undefined => { - try { - const formatted = (0) - .toLocaleString('en-US', { - style: 'currency', - currency: isoCode, - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }) - .replace(/\d/g, '') - .trim(); - if (!formatted || formatted.toUpperCase() === isoCode.toUpperCase()) { - return undefined; - } - return formatted; - } catch { - return undefined; - } -}; - const UNAVAILABLE_DELTA_FIAT = '— '; const UNAVAILABLE_DELTA_PERCENT = ' — %'; @@ -1118,7 +1393,7 @@ const getEffectiveQuoteCurrencyFromSnapshots = (args: { }; const getEarliestSnapshotTimestampMs = (args: { - snapshotsByWalletId: {[walletId: string]: BalanceSnapshot[] | undefined}; + snapshotsByWalletId: BalanceSnapshotsByWalletId; walletById: Map; }): number | undefined => { let best: number | undefined; @@ -1152,7 +1427,7 @@ export type PortfolioPnlChangeForTimeframeResult = { }; export const getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots = (args: { - snapshotsByWalletId: {[walletId: string]: BalanceSnapshot[] | undefined}; + snapshotsByWalletId: BalanceSnapshotsByWalletId; wallets: Wallet[]; quoteCurrency: string; timeframe: FiatRateInterval; @@ -1161,15 +1436,8 @@ export const getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots = (args: { fiatRateSeriesCache?: FiatRateSeriesCache; nowMs?: number; }): PortfolioPnlChangeForTimeframeResult => { - const preferredQuoteCurrency = (args.quoteCurrency || '').toUpperCase(); - const {effectiveQuoteCurrency, earliestSnapshotTimestampMs} = - buildPortfolioSnapshotContext({ - wallets: args.wallets, - snapshotsByWalletId: args.snapshotsByWalletId || {}, - preferredQuoteCurrency, - }); - - const nowMs = typeof args.nowMs === 'number' ? args.nowMs : Date.now(); + const plan = buildPreparedPortfolioPnlWalletPlan(args); + const {effectiveQuoteCurrency, earliestSnapshotTimestampMs, nowMs} = plan; const baselineTimestampMs = (() => { const ts = getFiatRateBaselineTsForTimeframe({ timeframe: args.timeframe, @@ -1204,96 +1472,26 @@ export const getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots = (args: { }); } - // Prefer the PnL engine used by AssetsList.tsx so key-level % changes and - // allocation box summaries are consistent everywhere. - const pnlWallets: WalletForAnalysis[] = []; - const currentRatesByCoin: Record = {}; - - for (const w of args.wallets || []) { - if ((w as any)?.network !== Network.mainnet) continue; - - const walletId = String((w as any)?.id || ''); - const coin = String((w as any)?.currencyAbbreviation || '').toLowerCase(); - if (!walletId || !coin) continue; - - const appSnaps = ensureSortedSnapshots( - args.snapshotsByWalletId?.[walletId], - ); - if (!appSnaps.length) continue; - - const unitInfo = getWalletUnitInfo(w); - const chainLower = String((w as any)?.chain || coin).toLowerCase(); - - const credentials: any = { - chain: chainLower, - coin, - network: - (w as any)?.network === Network.mainnet - ? 'livenet' - : String((w as any)?.network || 'livenet'), - }; - const tokenAddress = (w as any)?.tokenAddress as string | undefined; - if (tokenAddress) { - credentials.token = { - ...(credentials.token || {}), - decimals: unitInfo.unitDecimals, - address: tokenAddress, - }; - } - - const snaps = mapSnapshotsToStored({ - snapshots: appSnaps, - wallet: w, - walletId, - unitDecimals: unitInfo.unitDecimals, - fallbackChain: chainLower, - fallbackCoin: coin, - fallbackQuoteCurrency: effectiveQuoteCurrency, - targetQuoteCurrency: effectiveQuoteCurrency, - fiatRateSeriesCache: args.fiatRateSeriesCache, - nowMs, - fallbackAssetIdToWalletIdentity: true, - }); - - pnlWallets.push({ - walletId, - walletName: String( - (w as any)?.walletName || (w as any)?.name || walletId, - ), - currencyAbbreviation: coin, - credentials, - snapshots: snaps, - }); - - const normCoin = normalizeCoinForPnlRates(coin); - if (!(normCoin in currentRatesByCoin)) { - const currentRate = getQuoteRateNumForAsset({ - rates: args.rates, - quoteCurrency: effectiveQuoteCurrency, - coin, - chain: String((w as any)?.chain || coin), - tokenAddress, - }); - if (currentRate > 0) { - currentRatesByCoin[normCoin] = currentRate; - } - } - } + const preparedInputs = buildPnlWalletInputsFromPreparedPlan({ + plan, + rates: args.rates, + fiatRateSeriesCache: args.fiatRateSeriesCache, + }); - if (!pnlWallets.length) { + if (!preparedInputs.wallets.length) { return zeroResult({available: true}); } let res: ReturnType; try { res = buildPnlAnalysisSeries({ - wallets: pnlWallets, - timeframe: args.timeframe as any, - quoteCurrency: effectiveQuoteCurrency, - fiatRateSeriesCache: args.fiatRateSeriesCache as any, - currentRatesByCoin: - Object.keys(currentRatesByCoin).length > 0 - ? currentRatesByCoin + wallets: preparedInputs.wallets, + timeframe: args.timeframe, + quoteCurrency: preparedInputs.quoteCurrency, + fiatRateSeriesCache: args.fiatRateSeriesCache, + currentRatesByRateKey: + Object.keys(preparedInputs.currentRatesByRateKey).length > 0 + ? preparedInputs.currentRatesByRateKey : undefined, nowMs, maxPoints: 2, @@ -1327,6 +1525,419 @@ export const getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots = (args: { }; }; +/** + * Build the wallet+snapshot inputs required by the PnL analysis engine. + * + * This is factored out so balance-history charts can reuse the same conversion + * logic already used by AssetsList/portfolio PnL computations. + */ +export type PnlWalletInputs = { + wallets: WalletForAnalysis[]; + currentRatesByRateKey: Record; + quoteCurrency: string; +}; + +type PnlWalletBuildContext = { + wallet: Wallet; + walletId: string; + walletName: string; + coin: string; + chainLower: string; + tokenAddress?: string; + unitDecimals: number; + appSnaps: BalanceSnapshot[]; + credentials: WalletForAnalysis['credentials']; +}; + +type PnlWalletAnalysisEntry = { + wallet: WalletForAnalysis; + rateKey: string; + currentRate: number; +}; + +type PreparedPortfolioPnlWalletPlan = { + effectiveQuoteCurrency: string; + earliestSnapshotTimestampMs?: number; + nowMs: number; + walletContexts: PnlWalletBuildContext[]; +}; + +const createEmptyPnlWalletInputs = ( + quoteCurrency: string, +): PnlWalletInputs => ({ + wallets: [], + currentRatesByRateKey: {}, + quoteCurrency, +}); + +const createPnlWalletBuildContext = (args: { + wallet: Wallet; + snapshotsByWalletId: BalanceSnapshotsByWalletId; +}): PnlWalletBuildContext | undefined => { + const {wallet} = args; + + if (!isPortfolioWalletOnMainnet(wallet)) { + return undefined; + } + + const walletId = getPortfolioWalletId(wallet); + const coin = getPortfolioWalletCurrencyAbbreviationLower(wallet); + if (!walletId || !coin) { + return undefined; + } + + const appSnaps = ensureSortedSnapshots( + getPortfolioWalletSnapshots(args.snapshotsByWalletId, walletId), + ); + if (!appSnaps.length) { + return undefined; + } + + const unitInfo = getWalletUnitInfo(wallet); + const chainLower = getPortfolioWalletChainLower(wallet, coin); + const tokenAddress = getPortfolioWalletTokenAddress(wallet); + const credentials: WalletForAnalysis['credentials'] = { + chain: chainLower, + coin, + network: 'livenet', + }; + + if (tokenAddress) { + credentials.token = { + ...(credentials.token || {}), + decimals: unitInfo.unitDecimals, + address: tokenAddress, + }; + } + + return { + wallet, + walletId, + walletName: getPortfolioWalletDisplayName(wallet, walletId), + coin, + chainLower, + tokenAddress, + unitDecimals: unitInfo.unitDecimals, + appSnaps, + credentials, + }; +}; + +const buildPreparedPortfolioPnlWalletPlan = (args: { + snapshotsByWalletId: BalanceSnapshotsByWalletId; + wallets: Wallet[]; + quoteCurrency: string; + nowMs?: number; +}): PreparedPortfolioPnlWalletPlan => { + const {effectiveQuoteCurrency, earliestSnapshotTimestampMs} = + buildPortfolioSnapshotContext({ + wallets: args.wallets, + snapshotsByWalletId: args.snapshotsByWalletId || {}, + preferredQuoteCurrency: (args.quoteCurrency || '').toUpperCase(), + }); + + const walletContexts: PnlWalletBuildContext[] = []; + for (const wallet of args.wallets || []) { + const context = createPnlWalletBuildContext({ + wallet, + snapshotsByWalletId: args.snapshotsByWalletId || {}, + }); + if (context) { + walletContexts.push(context); + } + } + + return { + effectiveQuoteCurrency, + earliestSnapshotTimestampMs, + nowMs: typeof args.nowMs === 'number' ? args.nowMs : Date.now(), + walletContexts, + }; +}; + +const getPnlCurrentRateForWalletContext = (args: { + context: PnlWalletBuildContext; + effectiveQuoteCurrency: string; + rates?: Rates; +}): number => { + const {context, effectiveQuoteCurrency, rates} = args; + + return getQuoteRateNumForAsset({ + rates, + quoteCurrency: effectiveQuoteCurrency, + coin: context.coin, + chain: getPortfolioWalletChain(context.wallet, context.coin), + tokenAddress: context.tokenAddress, + }); +}; + +const getPnlHistoricalRateKeyForWalletContext = ( + context: Pick, +): string => { + return getFiatRateSeriesAssetKey(context.coin, { + chain: context.tokenAddress ? context.chainLower : undefined, + tokenAddress: context.tokenAddress, + }); +}; + +const buildPnlCurrentRatesByRateKeyFromWalletContexts = (args: { + walletContexts: PnlWalletBuildContext[]; + effectiveQuoteCurrency: string; + rates?: Rates; +}): Record => { + const currentRatesByRateKey: Record = {}; + + for (const context of args.walletContexts || []) { + const rateKey = getPnlHistoricalRateKeyForWalletContext(context); + if (rateKey in currentRatesByRateKey) { + continue; + } + + const currentRate = getPnlCurrentRateForWalletContext({ + context, + effectiveQuoteCurrency: args.effectiveQuoteCurrency, + rates: args.rates, + }); + if (currentRate > 0) { + currentRatesByRateKey[rateKey] = currentRate; + } + } + + return currentRatesByRateKey; +}; + +const appendPnlWalletAnalysisEntry = (args: { + inputs: PnlWalletInputs; + entry: PnlWalletAnalysisEntry; +}) => { + const {inputs, entry} = args; + + inputs.wallets.push(entry.wallet); + if ( + !(entry.rateKey in inputs.currentRatesByRateKey) && + entry.currentRate > 0 + ) { + inputs.currentRatesByRateKey[entry.rateKey] = entry.currentRate; + } +}; + +const buildPnlWalletMapArgs = (args: { + context: PnlWalletBuildContext; + effectiveQuoteCurrency: string; + fiatRateSeriesCache: FiatRateSeriesCache; + nowMs: number; + onHistoricalRateDependency?: (cacheKey: string) => void; +}): MapSnapshotsToStoredArgs => ({ + snapshots: args.context.appSnaps, + wallet: args.context.wallet, + walletId: args.context.walletId, + unitDecimals: args.context.unitDecimals, + fallbackChain: args.context.chainLower, + fallbackCoin: args.context.coin, + fallbackQuoteCurrency: args.effectiveQuoteCurrency, + targetQuoteCurrency: args.effectiveQuoteCurrency, + fiatRateSeriesCache: args.fiatRateSeriesCache, + nowMs: args.nowMs, + fallbackAssetIdToWalletIdentity: true, + onHistoricalRateDependency: args.onHistoricalRateDependency, +}); + +const createPnlWalletAnalysisEntry = (args: { + context: PnlWalletBuildContext; + effectiveQuoteCurrency: string; + rates?: Rates; + snapshots: BalanceSnapshotStored[]; +}): PnlWalletAnalysisEntry => { + const {context, effectiveQuoteCurrency, rates, snapshots} = args; + + return { + wallet: { + walletId: context.walletId, + walletName: context.walletName, + currencyAbbreviation: context.coin, + credentials: context.credentials, + snapshots, + }, + rateKey: getPnlHistoricalRateKeyForWalletContext(context), + currentRate: getPnlCurrentRateForWalletContext({ + context, + effectiveQuoteCurrency, + rates, + }), + }; +}; + +const buildPnlWalletInputsFromPreparedPlan = (args: { + plan: PreparedPortfolioPnlWalletPlan; + rates?: Rates; + fiatRateSeriesCache?: FiatRateSeriesCache; + onHistoricalRateDependency?: (cacheKey: string) => void; +}): PnlWalletInputs => { + const inputs = createEmptyPnlWalletInputs(args.plan.effectiveQuoteCurrency); + const fiatRateSeriesCache = args.fiatRateSeriesCache; + + if (!fiatRateSeriesCache) { + return inputs; + } + + for (const context of args.plan.walletContexts) { + const snaps = mapSnapshotsToStored( + buildPnlWalletMapArgs({ + context, + effectiveQuoteCurrency: args.plan.effectiveQuoteCurrency, + fiatRateSeriesCache, + nowMs: args.plan.nowMs, + onHistoricalRateDependency: args.onHistoricalRateDependency, + }), + ); + + appendPnlWalletAnalysisEntry({ + inputs, + entry: createPnlWalletAnalysisEntry({ + context, + effectiveQuoteCurrency: args.plan.effectiveQuoteCurrency, + rates: args.rates, + snapshots: snaps, + }), + }); + } + + return inputs; +}; + +const buildPnlWalletInputsFromPreparedPlanAsync = async ( + args: { + plan: PreparedPortfolioPnlWalletPlan; + rates?: Rates; + fiatRateSeriesCache?: FiatRateSeriesCache; + onHistoricalRateDependency?: (cacheKey: string) => void; + }, + asyncOpts?: { + signal?: AbortSignal; + yieldEveryWallets?: number; + yieldEverySnapshots?: number; + yieldControl?: () => Promise; + }, +): Promise => { + const inputs = createEmptyPnlWalletInputs(args.plan.effectiveQuoteCurrency); + const fiatRateSeriesCache = args.fiatRateSeriesCache; + const yieldEveryWallets = Math.max( + 1, + Math.floor(asyncOpts?.yieldEveryWallets ?? 1), + ); + const yieldControl = asyncOpts?.yieldControl || yieldToEventLoop; + + if (!fiatRateSeriesCache) { + return inputs; + } + + throwIfAbortSignalAborted(asyncOpts?.signal); + + for ( + let walletIndex = 0; + walletIndex < args.plan.walletContexts.length; + walletIndex++ + ) { + throwIfAbortSignalAborted(asyncOpts?.signal); + const context = args.plan.walletContexts[walletIndex]; + const snaps = await mapSnapshotsToStoredAsync( + buildPnlWalletMapArgs({ + context, + effectiveQuoteCurrency: args.plan.effectiveQuoteCurrency, + fiatRateSeriesCache, + nowMs: args.plan.nowMs, + onHistoricalRateDependency: args.onHistoricalRateDependency, + }), + { + signal: asyncOpts?.signal, + yieldEverySnapshots: asyncOpts?.yieldEverySnapshots, + yieldControl, + }, + ); + + appendPnlWalletAnalysisEntry({ + inputs, + entry: createPnlWalletAnalysisEntry({ + context, + effectiveQuoteCurrency: args.plan.effectiveQuoteCurrency, + rates: args.rates, + snapshots: snaps, + }), + }); + + if ((walletIndex + 1) % yieldEveryWallets === 0) { + await yieldControl(); + throwIfAbortSignalAborted(asyncOpts?.signal); + } + } + + return inputs; +}; + +export const buildPnlCurrentRatesByRateKeyFromPortfolioSnapshots = (args: { + snapshotsByWalletId: BalanceSnapshotsByWalletId; + wallets: Wallet[]; + quoteCurrency: string; + rates?: Rates; +}): Record => { + const plan = buildPreparedPortfolioPnlWalletPlan(args); + + return buildPnlCurrentRatesByRateKeyFromWalletContexts({ + walletContexts: plan.walletContexts, + effectiveQuoteCurrency: plan.effectiveQuoteCurrency, + rates: args.rates, + }); +}; + +export const buildPnlWalletInputsFromPortfolioSnapshots = (args: { + snapshotsByWalletId: BalanceSnapshotsByWalletId; + wallets: Wallet[]; + quoteCurrency: string; + rates?: Rates; + fiatRateSeriesCache?: FiatRateSeriesCache; + nowMs?: number; + onHistoricalRateDependency?: (cacheKey: string) => void; +}): PnlWalletInputs => { + const plan = buildPreparedPortfolioPnlWalletPlan(args); + + return buildPnlWalletInputsFromPreparedPlan({ + plan, + rates: args.rates, + fiatRateSeriesCache: args.fiatRateSeriesCache, + onHistoricalRateDependency: args.onHistoricalRateDependency, + }); +}; + +export const buildPnlWalletInputsFromPortfolioSnapshotsAsync = async ( + args: { + snapshotsByWalletId: BalanceSnapshotsByWalletId; + wallets: Wallet[]; + quoteCurrency: string; + rates?: Rates; + fiatRateSeriesCache?: FiatRateSeriesCache; + nowMs?: number; + onHistoricalRateDependency?: (cacheKey: string) => void; + }, + asyncOpts?: { + signal?: AbortSignal; + yieldEveryWallets?: number; + yieldEverySnapshots?: number; + yieldControl?: () => Promise; + }, +): Promise => { + const plan = buildPreparedPortfolioPnlWalletPlan(args); + + return buildPnlWalletInputsFromPreparedPlanAsync( + { + plan, + rates: args.rates, + fiatRateSeriesCache: args.fiatRateSeriesCache, + onHistoricalRateDependency: args.onHistoricalRateDependency, + }, + asyncOpts, + ); +}; + export type PortfolioGainLossSummary = { quoteCurrency: string; total: { @@ -1409,44 +2020,41 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { const fiatRateSeriesCache = args.fiatRateSeriesCache; const getAssetKey = (w: Wallet): {key: string; coin: string} | null => { - const coin = String((w as any)?.currencyAbbreviation || '').toLowerCase(); + const coin = getPortfolioWalletCurrencyAbbreviationLower(w); if (!coin) return null; if (args.collapseAcrossChains) { return {key: coin, coin}; } - const chain = String((w as any)?.chain || '').toLowerCase(); - const tokenAddress = (w as any)?.tokenAddress as string | undefined; + const chain = getPortfolioWalletChainLower(w); + const tokenAddress = getPortfolioWalletTokenAddressNormalized(w); const assetId = tokenAddress - ? `${chain}:${coin}:${tokenAddress.toLowerCase()}` + ? `${chain}:${coin}:${tokenAddress}` : `${chain}:${coin}`; return {key: assetId, coin}; }; const toPnlWallet = (w: Wallet): WalletForAnalysis | null => { - const walletId = String((w as any)?.id || ''); - const currencyAbbreviation = String( - (w as any)?.currencyAbbreviation || '', - ).toLowerCase(); + const walletId = getPortfolioWalletId(w); + const currencyAbbreviation = getPortfolioWalletCurrencyAbbreviationLower(w); if (!walletId || !currencyAbbreviation) return null; const appSnaps = ensureSortedSnapshots( - args.snapshotsByWalletId?.[walletId], + getPortfolioWalletSnapshots(args.snapshotsByWalletId, walletId), ); if (!appSnaps.length) return null; const unitInfo = getWalletUnitInfo(w); - const credentials: any = { - chain: String((w as any)?.chain || currencyAbbreviation).toLowerCase(), + const credentials: WalletForAnalysis['credentials'] = { + chain: getPortfolioWalletChainLower(w, currencyAbbreviation), coin: currencyAbbreviation, - network: - (w as any)?.network === Network.mainnet - ? 'livenet' - : String((w as any)?.network || 'livenet'), + network: isPortfolioWalletOnMainnet(w) + ? 'livenet' + : toStringOrEmpty(w.network || 'livenet'), }; - const tokenAddress = (w as any)?.tokenAddress as string | undefined; + const tokenAddress = getPortfolioWalletTokenAddress(w); if (tokenAddress) { credentials.token = { ...(credentials.token || {}), @@ -1471,9 +2079,7 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { return { walletId, - walletName: String( - (w as any)?.walletName || (w as any)?.name || walletId, - ), + walletName: getPortfolioWalletDisplayName(w, walletId), currencyAbbreviation, credentials, snapshots: snaps, @@ -1483,7 +2089,7 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { // Group wallets by asset key (display grouping). const walletsByAssetKey = new Map(); for (const w of args.wallets || []) { - if ((w as any)?.network !== Network.mainnet) continue; + if (!isPortfolioWalletOnMainnet(w)) continue; const info = getAssetKey(w); if (!info) continue; const list = walletsByAssetKey.get(info.key) || []; @@ -1496,20 +2102,17 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { } // Precompute representative wallet per asset key and bucket analysis wallets - // by normalized rate coin. This keeps one coin's stale/missing cache entries - // from poisoning PnL for every asset row. + // by the exact historical-rate identity used by the backend/cache. const repWalletByAssetKey = new Map(); const coinByAssetKey = new Map(); - const pnlWalletsByRateCoin = new Map(); + const pnlWalletsByRateKey = new Map(); const seenPnlWalletIds = new Set(); - const currentRatesByCoin: Record = {}; + const currentRatesByRateKey: Record = {}; for (const [assetKey, groupWallets] of walletsByAssetKey.entries()) { const first = groupWallets[0]; - const coin = String( - (first as any)?.currencyAbbreviation || '', - ).toLowerCase(); + const coin = getPortfolioWalletCurrencyAbbreviationLower(first); if (!coin) { continue; } @@ -1519,25 +2122,30 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { const repWallet = args.collapseAcrossChains ? groupWallets.find( w => - String((w as any)?.chain || '').toLowerCase() === coin && - !(w as any)?.tokenAddress, + getPortfolioWalletChainLower(w) === coin && + !getPortfolioWalletTokenAddress(w), ) || first : first; repWalletByAssetKey.set(assetKey, repWallet); coinByAssetKey.set(assetKey, coin); - const normCoin = normalizeCoinForPnlRates(coin); - if (!(normCoin in currentRatesByCoin)) { + const rateKey = getFiatRateSeriesAssetKey(coin, { + chain: getPortfolioWalletTokenAddress(repWallet) + ? getPortfolioWalletChainLower(repWallet, coin) + : undefined, + tokenAddress: getPortfolioWalletTokenAddress(repWallet), + }); + if (!(rateKey in currentRatesByRateKey)) { const currentRate = getQuoteRateNumForAsset({ rates: args.rates, quoteCurrency, coin, - chain: String((repWallet as any)?.chain || coin), - tokenAddress: (repWallet as any)?.tokenAddress, + chain: getPortfolioWalletChain(repWallet, coin), + tokenAddress: getPortfolioWalletTokenAddress(repWallet), }); if (currentRate > 0) { - currentRatesByCoin[normCoin] = currentRate; + currentRatesByRateKey[rateKey] = currentRate; } } @@ -1546,9 +2154,9 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { if (!pw) continue; if (seenPnlWalletIds.has(pw.walletId)) continue; seenPnlWalletIds.add(pw.walletId); - const existing = pnlWalletsByRateCoin.get(normCoin) || []; + const existing = pnlWalletsByRateKey.get(rateKey) || []; existing.push(pw); - pnlWalletsByRateCoin.set(normCoin, existing); + pnlWalletsByRateKey.set(rateKey, existing); } } @@ -1556,26 +2164,26 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { typeof buildPnlAnalysisSeries >['points'][number]; - const lastPointByRateCoin = new Map(); + const lastPointByRateKey = new Map(); if (fiatRateSeriesCache) { - for (const [rateCoin, walletsForCoin] of pnlWalletsByRateCoin.entries()) { - if (!walletsForCoin.length) { + for (const [rateKey, walletsForRateKey] of pnlWalletsByRateKey.entries()) { + if (!walletsForRateKey.length) { continue; } - const currentRate = currentRatesByCoin[rateCoin]; + const currentRate = currentRatesByRateKey[rateKey]; const currentRateOverride = typeof currentRate === 'number' && Number.isFinite(currentRate) - ? {[rateCoin]: currentRate} + ? {[rateKey]: currentRate} : undefined; try { const res = buildPnlAnalysisSeries({ - wallets: walletsForCoin, - timeframe: timeframe as any, + wallets: walletsForRateKey, + timeframe, quoteCurrency, - fiatRateSeriesCache: fiatRateSeriesCache as any, - currentRatesByCoin: currentRateOverride, + fiatRateSeriesCache, + currentRatesByRateKey: currentRateOverride, nowMs, maxPoints: 2, }); @@ -1584,7 +2192,7 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { ? res.points[res.points.length - 1] : undefined; if (lastPoint) { - lastPointByRateCoin.set(rateCoin, lastPoint); + lastPointByRateKey.set(rateKey, lastPoint); } } catch { // Ignore and use fallback per-row rate-derived calculations below. @@ -1610,29 +2218,34 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { const repWallet = repWalletByAssetKey.get(assetKey) || groupWallets[0]; const coin = coinByAssetKey.get(assetKey) || - String((repWallet as any)?.currencyAbbreviation || '').toLowerCase(); + getPortfolioWalletCurrencyAbbreviationLower(repWallet); if (!coin) { continue; } - // Aggregate per-wallet PnL stats from the last point of this row's rate-coin series. + // Aggregate per-wallet PnL stats from the last point of this row's rate-key series. let fiatValue = 0; let pnlFiat = 0; let pnlRatio = 0; let hasRate = false; let hasPnl = false; - const rateCoin = normalizeCoinForPnlRates(coin); - const lastPoint = lastPointByRateCoin.get(rateCoin); + const rateKey = getFiatRateSeriesAssetKey(coin, { + chain: getPortfolioWalletTokenAddress(repWallet) + ? getPortfolioWalletChainLower(repWallet, coin) + : undefined, + tokenAddress: getPortfolioWalletTokenAddress(repWallet), + }); + const lastPoint = lastPointByRateKey.get(rateKey); if (lastPoint) { let basis = 0; let hasWalletPoints = false; for (const w of groupWallets) { - const wid = String((w as any)?.id || ''); + const wid = getPortfolioWalletId(w); if (!wid) continue; - const wp = (lastPoint as any).byWalletId?.[wid] as any; + const wp = lastPoint.byWalletId?.[wid]; if (!wp) continue; hasWalletPoints = true; @@ -1673,8 +2286,8 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { rates: args.rates, quoteCurrency, coin, - chain: String((repWallet as any)?.chain || coin), - tokenAddress: (repWallet as any)?.tokenAddress, + chain: getPortfolioWalletChain(repWallet, coin), + tokenAddress: getPortfolioWalletTokenAddress(repWallet), }); const units = Number(atomicToUnitString(totalAtomic, repUnitDecimals)); const unitsForDisplay = Number.isFinite(units) ? units : 0; @@ -1688,8 +2301,8 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { rates: args.lastDayRates, quoteCurrency, coin, - chain: String((repWallet as any)?.chain || coin), - tokenAddress: (repWallet as any)?.tokenAddress, + chain: getPortfolioWalletChain(repWallet, coin), + tokenAddress: getPortfolioWalletTokenAddress(repWallet), }); if (lastDayRateForDisplay > 0) { const lastDayFiatValue = unitsForDisplay * lastDayRateForDisplay; @@ -1709,8 +2322,8 @@ export const buildAssetRowItemsFromPortfolioSnapshots = (args: { key: assetKey, repWallet, coin, - chain: String((repWallet as any)?.chain || ''), - tokenAddress: (repWallet as any)?.tokenAddress, + chain: getPortfolioWalletChain(repWallet), + tokenAddress: getPortfolioWalletTokenAddress(repWallet), cryptoAmount, fiatValue, pnlFiat, diff --git a/src/utils/portfolio/chartCache.ts b/src/utils/portfolio/chartCache.ts new file mode 100644 index 0000000000..6dca3e95a0 --- /dev/null +++ b/src/utils/portfolio/chartCache.ts @@ -0,0 +1,797 @@ +import type {GraphPoint} from 'react-native-graph'; +import type { + FiatRateSeriesCache, + FiatRateSeriesCacheEntry, + FiatRateInterval, +} from '../../store/rate/rate.models'; +import type { + CachedBalanceChartTimeframe, + CachedBalanceChartTimeframes, + HistoricalRateDependencyMeta, +} from '../../store/portfolio-charts/portfolio-charts.models'; +import {BALANCE_CHART_CACHE_SCHEMA_VERSION} from '../../store/portfolio-charts/portfolio-charts.models'; +import type { + PnlAnalysisExactExtrema, + PnlAnalysisPoint, + WalletForAnalysis, +} from './core/pnl/analysis'; +import {getFiatRateSeriesAssetKey} from './core/fiatRateSeries'; +import {getAtomicDecimals, parseAtomicToBigint} from './core/format'; +import {atomicToUnitNumber} from './core/pnl/atomic'; +import { + normalizeGraphPointsForChart, + recomputeMinMaxFromGraphPoints, +} from './chartGraph'; + +export type CachedTimeframeStatus = + | 'fresh' + | 'patchable' + | 'stale_historical' + | 'missing'; + +export type HydratedBalanceChartSeries = { + graphPoints: GraphPoint[]; + analysisPoints: PnlAnalysisPoint[]; + pointByTimestamp: Map; + minIndex: number; + maxIndex: number; + minPoint: GraphPoint; + maxPoint: GraphPoint; +}; + +const SPOT_RATE_EPSILON = 1e-9; + +const toFiniteNumber = (value: unknown, fallback = 0): number => { + const normalized = typeof value === 'number' ? value : Number(value); + return Number.isFinite(normalized) ? normalized : fallback; +}; + +const toOptionalFiniteNumber = (value: unknown): number | undefined => { + const normalized = typeof value === 'number' ? value : Number(value); + return Number.isFinite(normalized) ? normalized : undefined; +}; + +export const normalizeBalanceChartOffset = (value: unknown): number => { + return toFiniteNumber(value, 0); +}; + +const findNearestGraphPointIndexByTimestamp = ( + graphPoints: GraphPoint[], + timestamp: number, +): number => { + if (!graphPoints.length || !Number.isFinite(timestamp)) { + return 0; + } + + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + + for (let index = 0; index < graphPoints.length; index++) { + const pointTs = graphPoints[index]?.date?.getTime?.(); + if (!Number.isFinite(pointTs)) { + continue; + } + + const distance = Math.abs(pointTs - timestamp); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + } + + return bestIndex; +}; + +export const resolveBalanceChartSeriesExtrema = (args: { + graphPoints: GraphPoint[]; + balanceOffset?: number; + exactExtrema?: PnlAnalysisExactExtrema; +}) => { + if (!args.exactExtrema) { + return recomputeMinMaxFromGraphPoints(args.graphPoints); + } + + const balanceOffset = normalizeBalanceChartOffset(args.balanceOffset); + const minIndex = findNearestGraphPointIndexByTimestamp( + args.graphPoints, + args.exactExtrema.min.timestamp, + ); + const maxIndex = findNearestGraphPointIndexByTimestamp( + args.graphPoints, + args.exactExtrema.max.timestamp, + ); + + return { + minIndex, + maxIndex, + minPoint: { + date: new Date(args.exactExtrema.min.timestamp), + value: args.exactExtrema.min.totalFiatBalance + balanceOffset, + }, + maxPoint: { + date: new Date(args.exactExtrema.max.timestamp), + value: args.exactExtrema.max.totalFiatBalance + balanceOffset, + }, + }; +}; + +const getExactExtremaFromCachedTimeframe = ( + cachedTimeframe: CachedBalanceChartTimeframe, +): PnlAnalysisExactExtrema | undefined => { + const minTotalFiatBalance = toOptionalFiniteNumber( + cachedTimeframe.minTotalFiatBalance, + ); + const minTotalFiatBalanceTs = toOptionalFiniteNumber( + cachedTimeframe.minTotalFiatBalanceTs, + ); + const maxTotalFiatBalance = toOptionalFiniteNumber( + cachedTimeframe.maxTotalFiatBalance, + ); + const maxTotalFiatBalanceTs = toOptionalFiniteNumber( + cachedTimeframe.maxTotalFiatBalanceTs, + ); + + if ( + minTotalFiatBalance === undefined || + minTotalFiatBalanceTs === undefined || + maxTotalFiatBalance === undefined || + maxTotalFiatBalanceTs === undefined + ) { + return undefined; + } + + const minExcludingEnd = (() => { + const totalFiatBalance = toOptionalFiniteNumber( + cachedTimeframe.minTotalFiatBalanceExcludingEnd, + ); + const timestamp = toOptionalFiniteNumber( + cachedTimeframe.minTotalFiatBalanceExcludingEndTs, + ); + return totalFiatBalance === undefined || timestamp === undefined + ? undefined + : {timestamp, totalFiatBalance}; + })(); + + const maxExcludingEnd = (() => { + const totalFiatBalance = toOptionalFiniteNumber( + cachedTimeframe.maxTotalFiatBalanceExcludingEnd, + ); + const timestamp = toOptionalFiniteNumber( + cachedTimeframe.maxTotalFiatBalanceExcludingEndTs, + ); + return totalFiatBalance === undefined || timestamp === undefined + ? undefined + : {timestamp, totalFiatBalance}; + })(); + + return { + min: { + timestamp: minTotalFiatBalanceTs, + totalFiatBalance: minTotalFiatBalance, + }, + max: { + timestamp: maxTotalFiatBalanceTs, + totalFiatBalance: maxTotalFiatBalance, + }, + minExcludingEnd, + maxExcludingEnd, + }; +}; + +export const getFiatRateSeriesCacheEntry = ( + cache: FiatRateSeriesCache | undefined, + cacheKey: string, +): FiatRateSeriesCacheEntry | undefined => { + if (!cacheKey) { + return undefined; + } + + return cache?.[cacheKey]; +}; + +export const getCachedBalanceChartTimeframe = ( + timeframes: CachedBalanceChartTimeframes | undefined, + timeframe: FiatRateInterval, +): CachedBalanceChartTimeframe | undefined => { + return timeframes?.[timeframe]; +}; + +export const getSortedUniqueWalletIds = (walletIds: string[]): string[] => { + const seen = new Set(); + const out: string[] = []; + for (const walletId of walletIds || []) { + const normalized = String(walletId || ''); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + out.push(normalized); + } + return out.sort((a, b) => a.localeCompare(b)); +}; + +const getWalletHistoricalRateKey = (wallet: WalletForAnalysis): string => { + const rawTokenAddress = wallet?.credentials?.token?.address; + const tokenAddress = + typeof rawTokenAddress === 'string' && rawTokenAddress.trim() + ? rawTokenAddress + : undefined; + + return getFiatRateSeriesAssetKey(wallet.currencyAbbreviation, { + chain: + tokenAddress && wallet?.credentials?.chain + ? String(wallet.credentials.chain) + : undefined, + tokenAddress, + }); +}; + +export const stableRateMapRevision = ( + ratesByRateKey?: Record, +): string => { + return Object.entries(ratesByRateKey || {}) + .filter(([, rate]) => Number.isFinite(rate)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([rateKey, rate]) => `${rateKey}:${Number(rate)}`) + .join('|'); +}; + +const toHistoricalDepSignature = ( + historicalRateDeps: HistoricalRateDependencyMeta[], +): string => { + return (historicalRateDeps || []) + .filter(dep => !!dep?.cacheKey) + .slice() + .sort((a, b) => a.cacheKey.localeCompare(b.cacheKey)) + .map( + dep => `${dep.cacheKey}:${dep.fetchedOn ?? 'na'}:${dep.lastTs ?? 'na'}`, + ) + .join('|'); +}; + +export const buildBalanceChartScopeId = (args: { + walletIds: string[]; + quoteCurrency: string; + balanceOffset?: number; +}): string => { + const walletIds = getSortedUniqueWalletIds(args.walletIds || []); + const quoteCurrency = String(args.quoteCurrency || '').toUpperCase(); + const balanceOffset = normalizeBalanceChartOffset(args.balanceOffset); + + return [ + `v${BALANCE_CHART_CACHE_SCHEMA_VERSION}`, + quoteCurrency, + balanceOffset, + walletIds.join(','), + ].join('|'); +}; + +export const buildSnapshotVersionSig = (args: { + walletIds: string[]; + walletSnapshotVersionById: Record; +}): string => { + return getSortedUniqueWalletIds(args.walletIds || []) + .map( + walletId => + `${walletId}:${Math.max( + 0, + Math.floor(args.walletSnapshotVersionById?.[walletId] || 0), + )}`, + ) + .join('|'); +}; + +const getLatestSeriesPointTs = ( + cache: FiatRateSeriesCache | undefined, + cacheKey: string, +): number | undefined => { + const points = getFiatRateSeriesCacheEntry(cache, cacheKey)?.points; + if (!Array.isArray(points) || !points.length) { + return undefined; + } + const ts = Number(points[points.length - 1]?.ts); + return Number.isFinite(ts) ? ts : undefined; +}; + +export const buildHistoricalRateDependencyMetadataFromCache = (args: { + depKeys: Iterable; + fiatRateSeriesCache: FiatRateSeriesCache | undefined; +}): HistoricalRateDependencyMeta[] => { + const cache = args.fiatRateSeriesCache; + const cacheKeys = Array.from(new Set(Array.from(args.depKeys || []))) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + + return cacheKeys.map(cacheKey => ({ + cacheKey, + fetchedOn: toOptionalFiniteNumber( + getFiatRateSeriesCacheEntry(cache, cacheKey)?.fetchedOn, + ), + lastTs: getLatestSeriesPointTs(cache, cacheKey), + })); +}; + +const haveHistoricalRateDependenciesChanged = (args: { + historicalRateDeps: HistoricalRateDependencyMeta[]; + fiatRateSeriesCache: FiatRateSeriesCache | undefined; +}): boolean => { + for (const dep of args.historicalRateDeps || []) { + if (!dep?.cacheKey) { + continue; + } + const current = getFiatRateSeriesCacheEntry( + args.fiatRateSeriesCache, + dep.cacheKey, + ); + if (!current) { + return true; + } + const currentFetchedOn = toOptionalFiniteNumber(current.fetchedOn); + const currentLastTs = getLatestSeriesPointTs( + args.fiatRateSeriesCache, + dep.cacheKey, + ); + if (dep.fetchedOn !== currentFetchedOn || dep.lastTs !== currentLastTs) { + return true; + } + } + return false; +}; + +const isSpotRateDifferent = (a: number, b: number): boolean => { + if (!(Number.isFinite(a) && Number.isFinite(b))) { + return true; + } + return Math.abs(a - b) > SPOT_RATE_EPSILON; +}; + +const getPatchableSpotRateChange = (args: { + cachedTimeframe: CachedBalanceChartTimeframe; + currentSpotRatesByRateKey: Record; +}): {patchable: boolean; changed: boolean} => { + const relevantRateKeys = Object.keys( + args.cachedTimeframe.latestHoldingsByRateKey || {}, + ) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + + if (!relevantRateKeys.length) { + return {patchable: false, changed: false}; + } + + let changed = false; + for (const rateKey of relevantRateKeys) { + const currentRate = args.currentSpotRatesByRateKey?.[rateKey]; + if ( + !( + typeof currentRate === 'number' && + Number.isFinite(currentRate) && + currentRate > 0 + ) + ) { + return {patchable: false, changed}; + } + const cachedRate = args.cachedTimeframe.lastSpotRatesByRateKey?.[rateKey]; + if ( + !( + typeof cachedRate === 'number' && + Number.isFinite(cachedRate) && + cachedRate > 0 + ) + ) { + changed = true; + continue; + } + if (isSpotRateDifferent(currentRate, cachedRate)) { + changed = true; + } + } + + return { + patchable: true, + changed, + }; +}; + +export const getCachedTimeframeStatus = (args: { + cachedTimeframe?: CachedBalanceChartTimeframe; + snapshotVersionSig: string; + currentSpotRatesByRateKey: Record; + fiatRateSeriesCache: FiatRateSeriesCache | undefined; +}): CachedTimeframeStatus => { + const cachedTimeframe = args.cachedTimeframe; + if (!cachedTimeframe) { + return 'missing'; + } + + if (cachedTimeframe.schemaVersion !== BALANCE_CHART_CACHE_SCHEMA_VERSION) { + return 'stale_historical'; + } + + if (cachedTimeframe.snapshotVersionSig !== args.snapshotVersionSig) { + return 'stale_historical'; + } + + if ( + haveHistoricalRateDependenciesChanged({ + historicalRateDeps: cachedTimeframe.historicalRateDeps || [], + fiatRateSeriesCache: args.fiatRateSeriesCache, + }) + ) { + return 'stale_historical'; + } + + const spotRateChange = getPatchableSpotRateChange({ + cachedTimeframe, + currentSpotRatesByRateKey: args.currentSpotRatesByRateKey, + }); + if (spotRateChange.patchable && spotRateChange.changed) { + return 'patchable'; + } + + return 'fresh'; +}; + +export const buildBalanceChartTimeframeRevision = (args: { + scopeId: string; + timeframe: FiatRateInterval; + snapshotVersionSig: string; + historicalRateDeps: HistoricalRateDependencyMeta[]; + currentSpotRatesByRateKey: Record; +}): string => { + return [ + `v${BALANCE_CHART_CACHE_SCHEMA_VERSION}`, + args.scopeId, + args.timeframe, + args.snapshotVersionSig, + toHistoricalDepSignature(args.historicalRateDeps || []), + stableRateMapRevision(args.currentSpotRatesByRateKey), + ].join('|'); +}; + +export const deserializeCachedTimeframeToComputedSeries = ( + cachedTimeframe: CachedBalanceChartTimeframe, +): HydratedBalanceChartSeries => { + const length = Math.min( + cachedTimeframe.ts.length, + cachedTimeframe.totalFiatBalance.length, + cachedTimeframe.totalUnrealizedPnlFiat.length, + cachedTimeframe.totalPnlPercent.length, + ); + + const analysisPoints: PnlAnalysisPoint[] = []; + const rawGraphPoints: GraphPoint[] = []; + + for (let i = 0; i < length; i++) { + const timestamp = toFiniteNumber(cachedTimeframe.ts[i], Date.now() + i); + const totalFiatBalance = toFiniteNumber( + cachedTimeframe.totalFiatBalance[i], + 0, + ); + const totalUnrealizedPnlFiat = toFiniteNumber( + cachedTimeframe.totalUnrealizedPnlFiat[i], + 0, + ); + const totalRemainingCostBasisFiat = + totalFiatBalance - totalUnrealizedPnlFiat; + const totalPnlPercent = toFiniteNumber( + cachedTimeframe.totalPnlPercent[i], + 0, + ); + + analysisPoints.push({ + timestamp, + totalFiatBalance, + totalRemainingCostBasisFiat, + totalUnrealizedPnlFiat, + totalPnlPercent, + byWalletId: {}, + }); + rawGraphPoints.push({ + date: new Date(timestamp), + value: + totalFiatBalance + + normalizeBalanceChartOffset(cachedTimeframe.balanceOffset), + }); + } + + const graphPoints = normalizeGraphPointsForChart(rawGraphPoints); + const pointByTimestamp = new Map(); + for (let i = 0; i < graphPoints.length; i++) { + pointByTimestamp.set(graphPoints[i].date.getTime(), analysisPoints[i]); + } + + const {minIndex, maxIndex, minPoint, maxPoint} = + resolveBalanceChartSeriesExtrema({ + graphPoints, + balanceOffset: cachedTimeframe.balanceOffset, + exactExtrema: getExactExtremaFromCachedTimeframe(cachedTimeframe), + }); + + return { + graphPoints, + analysisPoints, + pointByTimestamp, + minIndex, + maxIndex, + minPoint, + maxPoint, + }; +}; + +export const buildLatestPointPatchMetadataFromAnalysis = (args: { + analysisPoints: PnlAnalysisPoint[]; + wallets: WalletForAnalysis[]; +}): { + lastSpotRatesByRateKey: Record; + latestHoldingsByRateKey: Record; + latestRemainingCostBasisFiatTotal: number; +} => { + const analysisPoints = args.analysisPoints || []; + const latestPoint = analysisPoints.length + ? analysisPoints[analysisPoints.length - 1] + : undefined; + + const latestHoldingsByRateKey: Record = {}; + const lastSpotRatesByRateKey: Record = {}; + + if (latestPoint) { + for (const wallet of args.wallets || []) { + const walletPoint = latestPoint.byWalletId?.[wallet.walletId]; + if (!walletPoint) { + continue; + } + const decimals = getAtomicDecimals(wallet.credentials); + const units = atomicToUnitNumber( + parseAtomicToBigint(walletPoint.balanceAtomic || '0'), + decimals, + ); + const rateKey = getWalletHistoricalRateKey(wallet); + if (!latestHoldingsByRateKey[rateKey]) { + latestHoldingsByRateKey[rateKey] = {units: 0}; + } + latestHoldingsByRateKey[rateKey].units += units; + + if ( + !(rateKey in lastSpotRatesByRateKey) && + typeof walletPoint.markRate === 'number' && + Number.isFinite(walletPoint.markRate) && + walletPoint.markRate > 0 + ) { + lastSpotRatesByRateKey[rateKey] = walletPoint.markRate; + } + } + } + + return { + lastSpotRatesByRateKey, + latestHoldingsByRateKey, + latestRemainingCostBasisFiatTotal: toFiniteNumber( + latestPoint?.totalRemainingCostBasisFiat, + 0, + ), + }; +}; + +export const serializeComputedSeriesToCachedTimeframe = (args: { + timeframe: FiatRateInterval; + walletIds: string[]; + quoteCurrency: string; + balanceOffset: number; + snapshotVersionSig: string; + historicalRateDeps: HistoricalRateDependencyMeta[]; + analysisPoints: PnlAnalysisPoint[]; + exactExtrema?: PnlAnalysisExactExtrema; + patchMetadata: { + lastSpotRatesByRateKey: Record; + latestHoldingsByRateKey: Record; + latestRemainingCostBasisFiatTotal: number; + }; + builtAt?: number; +}): CachedBalanceChartTimeframe => { + const ts: number[] = []; + const totalFiatBalance: number[] = []; + const totalUnrealizedPnlFiat: number[] = []; + const totalPnlPercent: number[] = []; + + for (const point of args.analysisPoints || []) { + ts.push(toFiniteNumber(point?.timestamp, Date.now())); + totalFiatBalance.push(toFiniteNumber(point?.totalFiatBalance, 0)); + totalUnrealizedPnlFiat.push( + toFiniteNumber(point?.totalUnrealizedPnlFiat, 0), + ); + totalPnlPercent.push(toFiniteNumber(point?.totalPnlPercent, 0)); + } + + return { + timeframe: args.timeframe, + builtAt: + typeof args.builtAt === 'number' && Number.isFinite(args.builtAt) + ? args.builtAt + : Date.now(), + schemaVersion: BALANCE_CHART_CACHE_SCHEMA_VERSION, + quoteCurrency: String(args.quoteCurrency || '').toUpperCase(), + balanceOffset: normalizeBalanceChartOffset(args.balanceOffset), + walletIds: getSortedUniqueWalletIds(args.walletIds || []), + snapshotVersionSig: args.snapshotVersionSig, + historicalRateDeps: (args.historicalRateDeps || []) + .filter(dep => !!dep?.cacheKey) + .slice() + .sort((a, b) => a.cacheKey.localeCompare(b.cacheKey)) + .map(dep => ({ + cacheKey: dep.cacheKey, + fetchedOn: toOptionalFiniteNumber(dep.fetchedOn), + lastTs: toOptionalFiniteNumber(dep.lastTs), + })), + lastSpotRatesByRateKey: { + ...(args.patchMetadata?.lastSpotRatesByRateKey || {}), + }, + latestHoldingsByRateKey: { + ...(args.patchMetadata?.latestHoldingsByRateKey || {}), + }, + latestRemainingCostBasisFiatTotal: toFiniteNumber( + args.patchMetadata?.latestRemainingCostBasisFiatTotal, + 0, + ), + ts, + totalFiatBalance, + totalUnrealizedPnlFiat, + totalPnlPercent, + minTotalFiatBalance: toOptionalFiniteNumber( + args.exactExtrema?.min.totalFiatBalance, + ), + minTotalFiatBalanceTs: toOptionalFiniteNumber( + args.exactExtrema?.min.timestamp, + ), + maxTotalFiatBalance: toOptionalFiniteNumber( + args.exactExtrema?.max.totalFiatBalance, + ), + maxTotalFiatBalanceTs: toOptionalFiniteNumber( + args.exactExtrema?.max.timestamp, + ), + minTotalFiatBalanceExcludingEnd: toOptionalFiniteNumber( + args.exactExtrema?.minExcludingEnd?.totalFiatBalance, + ), + minTotalFiatBalanceExcludingEndTs: toOptionalFiniteNumber( + args.exactExtrema?.minExcludingEnd?.timestamp, + ), + maxTotalFiatBalanceExcludingEnd: toOptionalFiniteNumber( + args.exactExtrema?.maxExcludingEnd?.totalFiatBalance, + ), + maxTotalFiatBalanceExcludingEndTs: toOptionalFiniteNumber( + args.exactExtrema?.maxExcludingEnd?.timestamp, + ), + }; +}; + +export const patchCachedLatestPointWithSpotRates = (args: { + cachedTimeframe: CachedBalanceChartTimeframe; + currentSpotRatesByRateKey: Record; + patchedAt?: number; +}): CachedBalanceChartTimeframe => { + const spotRateChange = getPatchableSpotRateChange({ + cachedTimeframe: args.cachedTimeframe, + currentSpotRatesByRateKey: args.currentSpotRatesByRateKey, + }); + + if (!spotRateChange.patchable || !spotRateChange.changed) { + return args.cachedTimeframe; + } + + const lastIndex = args.cachedTimeframe.totalFiatBalance.length - 1; + if (lastIndex < 0) { + return args.cachedTimeframe; + } + + let latestTotalFiatBalance = 0; + for (const [rateKey, entry] of Object.entries( + args.cachedTimeframe.latestHoldingsByRateKey || {}, + )) { + const units = toFiniteNumber(entry?.units, 0); + const currentRate = args.currentSpotRatesByRateKey?.[rateKey]; + if (!(Number.isFinite(currentRate) && currentRate > 0)) { + return args.cachedTimeframe; + } + latestTotalFiatBalance += units * currentRate; + } + + const latestRemainingCostBasisFiatTotal = toFiniteNumber( + args.cachedTimeframe.latestRemainingCostBasisFiatTotal, + 0, + ); + const latestTotalUnrealizedPnlFiat = + latestTotalFiatBalance - latestRemainingCostBasisFiatTotal; + const latestTotalPnlPercent = + latestRemainingCostBasisFiatTotal > 0 + ? (latestTotalUnrealizedPnlFiat / latestRemainingCostBasisFiatTotal) * 100 + : 0; + + const nextTotalFiatBalance = args.cachedTimeframe.totalFiatBalance.slice(); + const nextTotalUnrealizedPnlFiat = + args.cachedTimeframe.totalUnrealizedPnlFiat.slice(); + const nextTotalPnlPercent = args.cachedTimeframe.totalPnlPercent.slice(); + + nextTotalFiatBalance[lastIndex] = latestTotalFiatBalance; + nextTotalUnrealizedPnlFiat[lastIndex] = latestTotalUnrealizedPnlFiat; + nextTotalPnlPercent[lastIndex] = latestTotalPnlPercent; + + const exactExtrema = getExactExtremaFromCachedTimeframe(args.cachedTimeframe); + const latestTimestamp = toOptionalFiniteNumber( + args.cachedTimeframe.ts[lastIndex], + ); + const latestPoint = + latestTimestamp === undefined + ? undefined + : { + timestamp: latestTimestamp, + totalFiatBalance: latestTotalFiatBalance, + }; + + const historicalMin = + exactExtrema && latestTimestamp !== undefined + ? exactExtrema.min.timestamp === latestTimestamp + ? exactExtrema.minExcludingEnd + : exactExtrema.min + : undefined; + const historicalMax = + exactExtrema && latestTimestamp !== undefined + ? exactExtrema.max.timestamp === latestTimestamp + ? exactExtrema.maxExcludingEnd + : exactExtrema.max + : undefined; + + const nextMinPoint = exactExtrema + ? latestPoint && + (!historicalMin || + latestPoint.totalFiatBalance < historicalMin.totalFiatBalance) + ? latestPoint + : historicalMin + : undefined; + const nextMaxPoint = exactExtrema + ? latestPoint && + (!historicalMax || + latestPoint.totalFiatBalance > historicalMax.totalFiatBalance) + ? latestPoint + : historicalMax + : undefined; + + const nextLastSpotRatesByRateKey = { + ...args.cachedTimeframe.lastSpotRatesByRateKey, + }; + for (const rateKey of Object.keys( + args.cachedTimeframe.latestHoldingsByRateKey || {}, + )) { + const currentRate = args.currentSpotRatesByRateKey[rateKey]; + if (Number.isFinite(currentRate) && currentRate > 0) { + nextLastSpotRatesByRateKey[rateKey] = currentRate; + } + } + + return { + ...args.cachedTimeframe, + builtAt: + typeof args.patchedAt === 'number' && Number.isFinite(args.patchedAt) + ? args.patchedAt + : Date.now(), + lastSpotRatesByRateKey: nextLastSpotRatesByRateKey, + totalFiatBalance: nextTotalFiatBalance, + totalUnrealizedPnlFiat: nextTotalUnrealizedPnlFiat, + totalPnlPercent: nextTotalPnlPercent, + minTotalFiatBalance: + nextMinPoint === undefined + ? args.cachedTimeframe.minTotalFiatBalance + : toOptionalFiniteNumber(nextMinPoint.totalFiatBalance), + minTotalFiatBalanceTs: + nextMinPoint === undefined + ? args.cachedTimeframe.minTotalFiatBalanceTs + : toOptionalFiniteNumber(nextMinPoint.timestamp), + maxTotalFiatBalance: + nextMaxPoint === undefined + ? args.cachedTimeframe.maxTotalFiatBalance + : toOptionalFiniteNumber(nextMaxPoint.totalFiatBalance), + maxTotalFiatBalanceTs: + nextMaxPoint === undefined + ? args.cachedTimeframe.maxTotalFiatBalanceTs + : toOptionalFiniteNumber(nextMaxPoint.timestamp), + }; +}; diff --git a/src/utils/portfolio/chartGraph.ts b/src/utils/portfolio/chartGraph.ts new file mode 100644 index 0000000000..9449897fff --- /dev/null +++ b/src/utils/portfolio/chartGraph.ts @@ -0,0 +1,85 @@ +import type {GraphPoint} from 'react-native-graph'; + +export const GRAPH_DRAWABLE_EPSILON = 0.0001; + +export const normalizeGraphPointsForChart = ( + points: GraphPoint[], +): GraphPoint[] => { + if (!points.length) { + return points; + } + + const normalized: GraphPoint[] = []; + const fallbackTsBase = Date.now(); + let prevTs = Number.NEGATIVE_INFINITY; + let minV = Number.POSITIVE_INFINITY; + let maxV = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < points.length; i++) { + const src = points[i]; + const rawTs = + src?.date instanceof Date + ? src.date.getTime() + : Number((src as {date?: unknown})?.date); + let ts = Number.isFinite(rawTs) ? rawTs : fallbackTsBase + i; + if (Number.isFinite(prevTs) && ts <= prevTs) { + ts = prevTs + 1; + } + + const fallbackValue = normalized.length + ? normalized[normalized.length - 1].value + : 0; + const value = Number.isFinite(src?.value) ? src.value : fallbackValue; + + normalized.push({ + date: new Date(ts), + value, + }); + prevTs = ts; + + if (value < minV) { + minV = value; + } + if (value > maxV) { + maxV = value; + } + } + + // react-native-graph may render nothing when all values are identical (0 range). + // Add a tiny epsilon to the last point to guarantee a drawable range without + // affecting formatted labels. + if (normalized.length >= 2 && minV === maxV) { + normalized[normalized.length - 1] = { + ...normalized[normalized.length - 1], + value: normalized[normalized.length - 1].value + GRAPH_DRAWABLE_EPSILON, + }; + } + + return normalized; +}; + +export const recomputeMinMaxFromGraphPoints = (points: GraphPoint[]) => { + let minIndex = 0; + let maxIndex = 0; + let minValue = Number.POSITIVE_INFINITY; + let maxValue = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < points.length; i++) { + const value = points[i]?.value; + if (value < minValue) { + minValue = value; + minIndex = i; + } + if (value > maxValue) { + maxValue = value; + maxIndex = i; + } + } + + return { + minIndex, + maxIndex, + minPoint: points[minIndex], + maxPoint: points[maxIndex], + }; +}; diff --git a/src/utils/portfolio/core/fiatRateSeries.ts b/src/utils/portfolio/core/fiatRateSeries.ts index f81d15a26e..7192f0e270 100644 --- a/src/utils/portfolio/core/fiatRateSeries.ts +++ b/src/utils/portfolio/core/fiatRateSeries.ts @@ -22,23 +22,167 @@ export type FiatRateSeriesCache = { [key in string]?: FiatRateSeries; }; +export type FiatRateSeriesAssetIdentity = { + coin?: string; + chain?: string; + tokenAddress?: string; +}; + +export type FiatRateSeriesReaderIdentity = Omit< + FiatRateSeriesAssetIdentity, + 'coin' +>; + +const FIAT_RATE_SERIES_ASSET_KEY_SEPARATOR = '|'; +const FIAT_RATE_SERIES_SVM_CHAINS = new Set(['sol', 'solana']); + +export const normalizeFiatRateSeriesCoin = ( + currencyAbbreviation?: string, +): string => { + switch ((currencyAbbreviation || '').toLowerCase()) { + case 'matic': + case 'pol': + return 'pol'; + default: + return (currencyAbbreviation || '').toLowerCase(); + } +}; + +export const normalizeFiatRateSeriesChain = ( + chain?: string, +): string | undefined => { + const normalized = String(chain || '') + .trim() + .toLowerCase(); + return normalized || undefined; +}; + +export const normalizeFiatRateSeriesTokenAddress = ( + chain?: string, + tokenAddress?: string, +): string | undefined => { + const normalized = String(tokenAddress || '').trim(); + if (!normalized) { + return undefined; + } + + return FIAT_RATE_SERIES_SVM_CHAINS.has(String(chain || '').toLowerCase()) + ? normalized + : normalized.toLowerCase(); +}; + +export const getFiatRateSeriesAssetKey = ( + coin: string, + identity?: FiatRateSeriesReaderIdentity, +): string => { + const normalizedCoin = normalizeFiatRateSeriesCoin(coin).trim(); + if (!normalizedCoin) { + return ''; + } + + const normalizedChain = normalizeFiatRateSeriesChain(identity?.chain); + const normalizedTokenAddress = normalizeFiatRateSeriesTokenAddress( + normalizedChain, + identity?.tokenAddress, + ); + + if (!normalizedChain && !normalizedTokenAddress) { + return normalizedCoin; + } + + return [ + normalizedCoin, + normalizedChain || '', + ...(normalizedTokenAddress ? [normalizedTokenAddress] : []), + ].join(FIAT_RATE_SERIES_ASSET_KEY_SEPARATOR); +}; + +export const parseFiatRateSeriesAssetKey = ( + assetKey: string, +): + | (Required> & + Pick) + | undefined => { + if (!assetKey || typeof assetKey !== 'string') { + return undefined; + } + + if (!assetKey.includes(FIAT_RATE_SERIES_ASSET_KEY_SEPARATOR)) { + const coin = normalizeFiatRateSeriesCoin(assetKey).trim(); + return coin ? {coin} : undefined; + } + + const [coinPart, chainPart = '', tokenPart = ''] = assetKey.split( + FIAT_RATE_SERIES_ASSET_KEY_SEPARATOR, + ); + const coin = normalizeFiatRateSeriesCoin(coinPart).trim(); + if (!coin) { + return undefined; + } + + const chain = normalizeFiatRateSeriesChain(chainPart); + const tokenAddress = normalizeFiatRateSeriesTokenAddress(chain, tokenPart); + + return { + coin, + ...(chain ? {chain} : {}), + ...(tokenAddress ? {tokenAddress} : {}), + }; +}; + export const getFiatRateSeriesCacheKey = ( fiatCode: string, coin: string, interval: FiatRateInterval, + identity?: FiatRateSeriesReaderIdentity, ): string => { - return `${(fiatCode || '').toUpperCase()}:${( - coin || '' - ).toLowerCase()}:${interval}`; + return `${(fiatCode || '').toUpperCase()}:${getFiatRateSeriesAssetKey( + coin, + identity, + )}:${interval}`; +}; + +export const parseFiatRateSeriesCacheKey = ( + cacheKey: string, +): + | ({ + fiatCode: string; + interval: string; + assetKey: string; + } & Required> & + Pick) + | undefined => { + if (!cacheKey || typeof cacheKey !== 'string') { + return undefined; + } + + const first = cacheKey.indexOf(':'); + const last = cacheKey.lastIndexOf(':'); + if (first <= 0 || last <= first + 1) { + return undefined; + } + + const fiatCode = cacheKey.slice(0, first).toUpperCase(); + const interval = cacheKey.slice(last + 1); + const assetKey = cacheKey.slice(first + 1, last); + const parsedAssetKey = parseFiatRateSeriesAssetKey(assetKey); + + if (!fiatCode || !interval || !parsedAssetKey?.coin) { + return undefined; + } + + return { + fiatCode, + interval, + assetKey, + ...parsedAssetKey, + }; }; const getFiatCodeFromSeriesCacheKey = ( cacheKey: string, ): string | undefined => { - if (!cacheKey || typeof cacheKey !== 'string') return undefined; - const idx = cacheKey.indexOf(':'); - if (idx <= 0) return undefined; - return cacheKey.slice(0, idx).toUpperCase(); + return parseFiatRateSeriesCacheKey(cacheKey)?.fiatCode; }; export function upsertFiatRateSeriesCache( diff --git a/src/utils/portfolio/core/fiatTimeframes.ts b/src/utils/portfolio/core/fiatTimeframes.ts new file mode 100644 index 0000000000..a213410e81 --- /dev/null +++ b/src/utils/portfolio/core/fiatTimeframes.ts @@ -0,0 +1,84 @@ +import type {FiatRateInterval} from './fiatRateSeries'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type FiatTimeframeSeriesInterval = 'ALL' | '1D' | '1W' | '1M'; + +export type FiatTimeframeMetadata = Readonly<{ + displayLabel: string; + rangeLabel: string; + windowMs?: number; + seriesInterval: FiatTimeframeSeriesInterval; +}>; + +export const FIAT_TIMEFRAME_VALUES = [ + 'ALL', + '1D', + '1W', + '1M', + '3M', + '1Y', + '5Y', +] as const satisfies ReadonlyArray; + +export const FIAT_TIMEFRAME_METADATA = { + ALL: { + displayLabel: 'All', + rangeLabel: 'All-time', + seriesInterval: 'ALL', + }, + '1D': { + displayLabel: '1D', + rangeLabel: 'Last Day', + windowMs: 1 * DAY_MS, + seriesInterval: '1D', + }, + '1W': { + displayLabel: '1W', + rangeLabel: 'Past Week', + windowMs: 7 * DAY_MS, + seriesInterval: '1W', + }, + '1M': { + displayLabel: '1M', + rangeLabel: 'Past Month', + windowMs: 30 * DAY_MS, + seriesInterval: '1M', + }, + '3M': { + displayLabel: '3M', + rangeLabel: 'Past 3 Months', + windowMs: 90 * DAY_MS, + seriesInterval: 'ALL', + }, + '1Y': { + displayLabel: '1Y', + rangeLabel: 'Past Year', + windowMs: 365 * DAY_MS, + seriesInterval: 'ALL', + }, + '5Y': { + displayLabel: '5Y', + rangeLabel: 'Past 5 Years', + windowMs: 1825 * DAY_MS, + seriesInterval: 'ALL', + }, +} as const satisfies Record; + +export const getFiatTimeframeMetadata = ( + timeframe: FiatRateInterval, +): FiatTimeframeMetadata => { + return FIAT_TIMEFRAME_METADATA[timeframe]; +}; + +export const getFiatTimeframeWindowMs = ( + timeframe: FiatRateInterval, +): number | undefined => { + return getFiatTimeframeMetadata(timeframe).windowMs; +}; + +export const getFiatTimeframeSeriesInterval = ( + timeframe: FiatRateInterval, +): FiatTimeframeSeriesInterval => { + return getFiatTimeframeMetadata(timeframe).seriesInterval; +}; diff --git a/src/utils/portfolio/core/index.ts b/src/utils/portfolio/core/index.ts index 4a3bf6c1cf..b570f8c819 100644 --- a/src/utils/portfolio/core/index.ts +++ b/src/utils/portfolio/core/index.ts @@ -1,6 +1,7 @@ export * from './types'; export * from './format'; export * from './fiatRateSeries'; +export * from './fiatTimeframes'; export * from './pnl/types'; export * from './pnl/rates'; diff --git a/src/utils/portfolio/core/pnl/analysis.ts b/src/utils/portfolio/core/pnl/analysis.ts index 9e51bde4ce..42f7f5435b 100644 --- a/src/utils/portfolio/core/pnl/analysis.ts +++ b/src/utils/portfolio/core/pnl/analysis.ts @@ -3,12 +3,16 @@ import type { FiatRateSeriesCache, FiatRatePoint, } from '../fiatRateSeries'; -import {getFiatRateSeriesCacheKey} from '../fiatRateSeries'; +import { + getFiatRateSeriesAssetKey, + getFiatRateSeriesCacheKey, +} from '../fiatRateSeries'; import { formatAtomicAmount, getAtomicDecimals, parseAtomicToBigint, } from '../format'; +import {throwIfAbortSignalAborted} from '../../../abort'; import type {WalletCredentials} from '../types'; import type {BalanceSnapshotStored} from './types'; import {normalizeFiatRateSeriesCoin} from './rates'; @@ -22,9 +26,12 @@ import { PREF_ALL, } from './intervalPrefs'; import {atomicToUnitNumber} from './atomic'; +import { + getFiatTimeframeSeriesInterval, + getFiatTimeframeWindowMs, +} from '../fiatTimeframes'; const MS_PER_HOUR = 60 * 60 * 1000; -const MS_PER_DAY = 24 * MS_PER_HOUR; export type PnlTimeframe = FiatRateInterval; @@ -73,7 +80,7 @@ export type PnlAnalysisPoint = { }; export type AssetPnlSummary = { - coin: string; + rateKey: string; displaySymbol: string; rateStart: number; rateEnd: number; @@ -92,13 +99,26 @@ export type TotalPnlSummary = { pnlPercent: number; }; +export type PnlAnalysisExtremaPoint = { + timestamp: number; + totalFiatBalance: number; +}; + +export type PnlAnalysisExactExtrema = { + min: PnlAnalysisExtremaPoint; + max: PnlAnalysisExtremaPoint; + minExcludingEnd?: PnlAnalysisExtremaPoint; + maxExcludingEnd?: PnlAnalysisExtremaPoint; +}; + export type PnlAnalysisResult = { timeframe: PnlTimeframe; quoteCurrency: string; - driverCoin: string; - coins: string[]; + driverRateKey: string; + rateKeys: string[]; wallets: WalletForAnalysis[]; points: PnlAnalysisPoint[]; + exactExtrema?: PnlAnalysisExactExtrema; assetSummaries: AssetPnlSummary[]; totalSummary: TotalPnlSummary; @@ -127,87 +147,76 @@ function buildEvenTimeline( return out; } -function getWindowMs(timeframe: PnlTimeframe): number { - switch (timeframe) { - case '1D': - return 1 * MS_PER_DAY; - case '1W': - return 7 * MS_PER_DAY; - case '1M': - return 30 * MS_PER_DAY; - case '3M': - return 90 * MS_PER_DAY; - case '1Y': - return 365 * MS_PER_DAY; - case '5Y': - return 1825 * MS_PER_DAY; - case 'ALL': - default: - return 0; - } -} - function roundDownToHourMs(tsMs: number): number { return Math.floor(tsMs / MS_PER_HOUR) * MS_PER_HOUR; } +const FALLBACK_ORDER_BY_TIMEFRAME: Record< + PnlTimeframe, + readonly FiatRateInterval[] +> = { + '1D': PREF_1D, + '1W': PREF_1W, + '1M': PREF_1M, + '3M': PREF_3M, + '1Y': PREF_1Y, + '5Y': PREF_5Y, + ALL: PREF_ALL, +}; + function getBaselineMs( timeframe: PnlTimeframe, nowMs: number, ): number | undefined { - if (timeframe === 'ALL') return undefined; - const win = getWindowMs(timeframe); - if (!win) return undefined; - return roundDownToHourMs(nowMs - win); + const windowMs = getFiatTimeframeWindowMs(timeframe); + if (typeof windowMs !== 'number') { + return undefined; + } + return roundDownToHourMs(nowMs - windowMs); } function getFallbackOrderForTimeframe( timeframe: PnlTimeframe, ): readonly FiatRateInterval[] { - switch (timeframe) { - case '1D': - return PREF_1D; - case '1W': - return PREF_1W; - case '1M': - return PREF_1M; - case '3M': - return PREF_3M; - case '1Y': - return PREF_1Y; - case '5Y': - return PREF_5Y; - case 'ALL': - default: - // ALL series may be missing for very new wallets unless rates were fetched explicitly. - // Prefer widest coverage first, but allow shorter windows for brand-new wallets. - return PREF_ALL; - } + // ALL series may be missing for very new wallets unless rates were fetched explicitly. + // Prefer widest coverage first, but allow shorter windows for brand-new wallets. + return FALLBACK_ORDER_BY_TIMEFRAME[timeframe]; } function getRatePointsFromCache(args: { fiatRateSeriesCache: FiatRateSeriesCache; quoteCurrency: string; - coin: string; + rateIdentity: WalletRateIdentity; /** Cache interval to query (may differ from timeframe; e.g. 3M/1Y/5Y use ALL in the app) */ seriesInterval: FiatRateInterval; /** Original timeframe (used only for fallback ordering) */ timeframe: PnlTimeframe; + onHistoricalRateDependency?: (cacheKey: string) => void; }): FiatRatePoint[] { - const {fiatRateSeriesCache, quoteCurrency, coin, timeframe, seriesInterval} = - args; + const { + fiatRateSeriesCache, + quoteCurrency, + rateIdentity, + timeframe, + seriesInterval, + } = args; // Ensure the requested seriesInterval is attempted first (e.g. 3M/1Y/5Y use ALL). const firstKey = getFiatRateSeriesCacheKey( quoteCurrency, - coin, + rateIdentity.coin, seriesInterval, + { + chain: rateIdentity.chain, + tokenAddress: rateIdentity.tokenAddress, + }, ); const firstSeries = fiatRateSeriesCache?.[firstKey]; const firstPoints = Array.isArray(firstSeries?.points) ? firstSeries.points : []; if (firstPoints.length) { + args.onHistoricalRateDependency?.(firstKey); return firstPoints; } @@ -217,18 +226,31 @@ function getRatePointsFromCache(args: { const fallbackIntervals = getFallbackOrderForTimeframe(timeframe); for (const interval of fallbackIntervals) { if (interval === seriesInterval) continue; - const key = getFiatRateSeriesCacheKey(quoteCurrency, coin, interval); + const key = getFiatRateSeriesCacheKey( + quoteCurrency, + rateIdentity.coin, + interval, + { + chain: rateIdentity.chain, + tokenAddress: rateIdentity.tokenAddress, + }, + ); const series = fiatRateSeriesCache?.[key]; const points = Array.isArray(series?.points) ? series.points : []; if (points.length) { + args.onHistoricalRateDependency?.(key); return points; } } const wantedKey = getFiatRateSeriesCacheKey( quoteCurrency, - coin, + rateIdentity.coin, seriesInterval, + { + chain: rateIdentity.chain, + tokenAddress: rateIdentity.tokenAddress, + }, ); throw new Error( `Missing cached rate for ${wantedKey}. Fetch rates first (1D/1W/1M/3M/1Y/5Y/ALL).`, @@ -281,6 +303,24 @@ type RateSeries = { rate: Float64Array; }; +function findFirstTimestampAtOrAfter( + ts: ArrayLike, + target: number, +): number { + const len = ts.length; + if (!len) return -1; + + let lo = 0; + let hi = len; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (ts[mid] < target) lo = mid + 1; + else hi = mid; + } + + return lo < len ? lo : -1; +} + function makeNearestRateCursor(series: RateSeries): RateCursor { // points must be sorted ascending by ts. let lo = 0; @@ -327,7 +367,14 @@ function makeNearestRateCursor(series: RateSeries): RateCursor { }; } -function buildRateSeries(points: FiatRatePoint[], minTs?: number): RateSeries { +export const pnlAnalysisInternals = { + makeNearestRateCursor, +}; + +export function buildRateSeries( + points: FiatRatePoint[], + minTs?: number, +): RateSeries { const tsList: number[] = []; const rateList: number[] = []; @@ -338,8 +385,6 @@ function buildRateSeries(points: FiatRatePoint[], minTs?: number): RateSeries { const ts = Number((p as any)?.ts); const rate = Number((p as any)?.rate); if (!Number.isFinite(ts) || !Number.isFinite(rate)) continue; - if (typeof minTs === 'number' && Number.isFinite(minTs) && ts < minTs) - continue; if (ts < prevTs) sorted = false; prevTs = ts; @@ -363,70 +408,557 @@ function buildRateSeries(points: FiatRatePoint[], minTs?: number): RateSeries { ts[i] = tsList[j]; rate[i] = rateList[j]; } + if (typeof minTs === 'number' && Number.isFinite(minTs) && ts.length > 0) { + let firstAtOrAfter = findFirstTimestampAtOrAfter(ts, minTs); + if (firstAtOrAfter < 0) { + return {ts: new Float64Array(0), rate: new Float64Array(0)}; + } + firstAtOrAfter = firstAtOrAfter > 0 ? firstAtOrAfter - 1 : 0; + return { + ts: ts.slice(firstAtOrAfter), + rate: rate.slice(firstAtOrAfter), + }; + } return {ts, rate}; } + if (typeof minTs === 'number' && Number.isFinite(minTs)) { + let firstAtOrAfter = findFirstTimestampAtOrAfter(tsList, minTs); + if (firstAtOrAfter < 0) { + return {ts: new Float64Array(0), rate: new Float64Array(0)}; + } + const startIdx = firstAtOrAfter > 0 ? firstAtOrAfter - 1 : 0; + return { + ts: Float64Array.from(tsList.slice(startIdx)), + rate: Float64Array.from(rateList.slice(startIdx)), + }; + } + return {ts: Float64Array.from(tsList), rate: Float64Array.from(rateList)}; } -function findFirstNonZeroBalanceTs( - wallets: WalletForAnalysis[], -): number | null { +function findOldestSnapshotTs(wallets: WalletForAnalysis[]): number | null { let best: number | null = null; for (const w of wallets) { for (const s of w.snapshots) { - const bal = parseAtomicToBigint(s.cryptoBalance); - if (bal > 0n) { - const ts = Number(s.timestamp); - if (!best || ts < best) best = ts; - break; - } + const ts = Number(s.timestamp); + if (!Number.isFinite(ts)) continue; + if (!best || ts < best) best = ts; + break; + } + } + return best; +} + +function findNewestSnapshotTs(wallets: WalletForAnalysis[]): number | null { + let best: number | null = null; + for (const w of wallets) { + for (let i = w.snapshots.length - 1; i >= 0; i--) { + const ts = Number(w.snapshots[i]?.timestamp); + if (!Number.isFinite(ts)) continue; + if (!best || ts > best) best = ts; + break; } } return best; } function isSingleAsset(wallets: WalletForAnalysis[]): boolean { - const coins = new Set(); + const rateKeys = new Set(); for (const w of wallets) { - coins.add(normalizeFiatRateSeriesCoin(w.currencyAbbreviation)); - if (coins.size > 1) return false; + rateKeys.add(getWalletRateIdentity(w).key); + if (rateKeys.size > 1) return false; } - return coins.size === 1; + return rateKeys.size === 1; } -export function buildPnlAnalysisSeries(args: { +type WalletRateIdentity = { + key: string; + coin: string; + chain?: string; + tokenAddress?: string; + displaySymbol: string; +}; + +const getWalletRateIdentity = ( + wallet: WalletForAnalysis, +): WalletRateIdentity => { + const coin = normalizeFiatRateSeriesCoin(wallet.currencyAbbreviation); + const rawTokenAddress = wallet?.credentials?.token?.address; + const tokenAddress = + typeof rawTokenAddress === 'string' && rawTokenAddress.trim() + ? rawTokenAddress + : undefined; + const chain = + tokenAddress && wallet?.credentials?.chain + ? String(wallet.credentials.chain) + : undefined; + + return { + key: getFiatRateSeriesAssetKey(coin, { + chain, + tokenAddress, + }), + coin, + ...(chain ? {chain} : {}), + ...(tokenAddress ? {tokenAddress} : {}), + displaySymbol: String(wallet.currencyAbbreviation || coin).toUpperCase(), + }; +}; + +type ExactExtremaEvent = + | { + timestamp: number; + type: 'rate'; + rateKey: string; + rate: number; + } + | { + timestamp: number; + type: 'balance'; + walletId: string; + rateKey: string; + units: number; + }; + +type RateTimestampGroup = { + timestamp: number; + firstRate: number; + lastRate: number; +}; + +const buildRateTimestampGroups = (series: RateSeries): RateTimestampGroup[] => { + const len = series.ts.length; + if (!len) { + return []; + } + + const groups: RateTimestampGroup[] = []; + let index = 0; + + while (index < len) { + const timestamp = series.ts[index]; + const firstRate = series.rate[index]; + let lastRate = firstRate; + + index++; + while (index < len && series.ts[index] === timestamp) { + lastRate = series.rate[index]; + index++; + } + + groups.push({ + timestamp, + firstRate, + lastRate, + }); + } + + return groups; +}; + +const getNearestRateSwitchTimestamp = ( + leftTs: number, + rightTs: number, +): number | undefined => { + if ( + !Number.isFinite(leftTs) || + !Number.isFinite(rightTs) || + rightTs <= leftTs + ) { + return undefined; + } + + return Math.floor((leftTs + rightTs) / 2) + 1; +}; + +type CooperativeYieldState = { + yieldEveryIterations: number; + iterations: number; +}; + +const buildCooperativeYieldState = ( + yieldEveryIterations?: number, +): CooperativeYieldState => ({ + yieldEveryIterations: + typeof yieldEveryIterations === 'number' && + Number.isFinite(yieldEveryIterations) && + yieldEveryIterations > 0 + ? Math.floor(yieldEveryIterations) + : 0, + iterations: 0, +}); + +function* maybeYieldForCooperativeWork( + state: CooperativeYieldState, +): Generator { + if (state.yieldEveryIterations <= 0) { + return; + } + + state.iterations++; + if (state.iterations % state.yieldEveryIterations === 0) { + yield; + } +} + +function* buildRateChangeEventsForExactExtremaGenerator(args: { + rateKey: string; + series: RateSeries; + startTs: number; + endTs: number; + yieldState: CooperativeYieldState; +}): Generator { + const groups = buildRateTimestampGroups(args.series); + const events: ExactExtremaEvent[] = []; + + for (let index = 0; index < groups.length; index++) { + yield* maybeYieldForCooperativeWork(args.yieldState); + + const group = groups[index]; + const prevGroup = index > 0 ? groups[index - 1] : undefined; + + if (prevGroup) { + const switchTimestamp = getNearestRateSwitchTimestamp( + prevGroup.timestamp, + group.timestamp, + ); + if ( + Number.isFinite(switchTimestamp) && + switchTimestamp > args.startTs && + switchTimestamp <= args.endTs + ) { + events.push({ + timestamp: switchTimestamp, + type: 'rate', + rateKey: args.rateKey, + rate: group.firstRate, + }); + } + } + + if ( + group.firstRate !== group.lastRate && + group.timestamp > args.startTs && + group.timestamp <= args.endTs + ) { + events.push({ + timestamp: group.timestamp, + type: 'rate', + rateKey: args.rateKey, + rate: group.lastRate, + }); + } + } + + return events; +} + +const updateExtremaPoint = ( + current: PnlAnalysisExtremaPoint | undefined, + next: PnlAnalysisExtremaPoint, + direction: 'min' | 'max', +): PnlAnalysisExtremaPoint => { + if (!current) { + return next; + } + + if (direction === 'min') { + return next.totalFiatBalance < current.totalFiatBalance ? next : current; + } + + return next.totalFiatBalance > current.totalFiatBalance ? next : current; +}; + +function* buildExactTotalFiatBalanceExtremaGenerator(args: { + wallets: WalletForAnalysis[]; + rateKeys: string[]; + rateIdentityByWalletId: Map; + rateSeriesByRateKey: Record; + startTs: number; + endTs: number; + getOverrideRate: (rateKey: string) => number | undefined; + yieldEveryIterations?: number; +}): Generator { + const startTs = Number(args.startTs); + const endTs = Number(args.endTs); + if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || endTs < startTs) { + return undefined; + } + + const unitsByRateKey: Record = {}; + const currentRateByRateKey: Record = {}; + const currentUnitsByWalletId: Record = {}; + const events: ExactExtremaEvent[] = []; + const yieldState = buildCooperativeYieldState(args.yieldEveryIterations); + + for (const rateKey of args.rateKeys) { + yield* maybeYieldForCooperativeWork(yieldState); + + unitsByRateKey[rateKey] = 0; + + const series = args.rateSeriesByRateKey[rateKey]; + const startRate = pnlAnalysisInternals + .makeNearestRateCursor(series) + .getNearest(startTs); + if (startRate === undefined) { + return undefined; + } + + currentRateByRateKey[rateKey] = startRate; + events.push( + ...(yield* buildRateChangeEventsForExactExtremaGenerator({ + rateKey, + series, + startTs, + endTs, + yieldState, + })), + ); + } + + for (const wallet of args.wallets) { + yield* maybeYieldForCooperativeWork(yieldState); + + const rateIdentity = args.rateIdentityByWalletId.get(wallet.walletId); + if (!rateIdentity?.key) { + continue; + } + + const snapshots = wallet.snapshots || []; + const decimals = getAtomicDecimals(wallet.credentials); + const startSnapshotIndex = findLastSnapshotIndexAtOrBefore( + snapshots, + startTs, + ); + const startUnits = atomicToUnitNumber( + startSnapshotIndex >= 0 + ? parseAtomicToBigint(snapshots[startSnapshotIndex].cryptoBalance) + : 0n, + decimals, + ); + + currentUnitsByWalletId[wallet.walletId] = startUnits; + unitsByRateKey[rateIdentity.key] = + (unitsByRateKey[rateIdentity.key] || 0) + startUnits; + + const nextSnapshotIndex = findFirstSnapshotIndexAfter(snapshots, startTs); + for (let i = nextSnapshotIndex; i < snapshots.length; i++) { + yield* maybeYieldForCooperativeWork(yieldState); + + const snapshot = snapshots[i]; + const timestamp = Number(snapshot?.timestamp); + if (!Number.isFinite(timestamp) || timestamp > endTs) { + break; + } + + events.push({ + timestamp, + type: 'balance', + walletId: wallet.walletId, + rateKey: rateIdentity.key, + units: atomicToUnitNumber( + parseAtomicToBigint(snapshot.cryptoBalance), + decimals, + ), + }); + } + } + + const computeTotalFiatBalance = (useEndOverrides = false): number => { + let totalFiatBalance = 0; + for (const rateKey of args.rateKeys) { + const overrideRate = useEndOverrides + ? args.getOverrideRate(rateKey) + : undefined; + const rate = + typeof overrideRate === 'number' && Number.isFinite(overrideRate) + ? overrideRate + : currentRateByRateKey[rateKey]; + totalFiatBalance += (unitsByRateKey[rateKey] || 0) * rate; + } + return totalFiatBalance; + }; + + let minPoint: PnlAnalysisExtremaPoint | undefined; + let maxPoint: PnlAnalysisExtremaPoint | undefined; + let minExcludingEnd: PnlAnalysisExtremaPoint | undefined; + let maxExcludingEnd: PnlAnalysisExtremaPoint | undefined; + + const recordPoint = ( + point: PnlAnalysisExtremaPoint, + includeExcludingEnd: boolean, + ) => { + minPoint = updateExtremaPoint(minPoint, point, 'min'); + maxPoint = updateExtremaPoint(maxPoint, point, 'max'); + + if (!includeExcludingEnd) { + return; + } + + minExcludingEnd = updateExtremaPoint(minExcludingEnd, point, 'min'); + maxExcludingEnd = updateExtremaPoint(maxExcludingEnd, point, 'max'); + }; + + if (startTs < endTs) { + recordPoint( + { + timestamp: startTs, + totalFiatBalance: computeTotalFiatBalance(false), + }, + true, + ); + } + + yield* maybeYieldForCooperativeWork(yieldState); + events.sort((a, b) => a.timestamp - b.timestamp); + yield* maybeYieldForCooperativeWork(yieldState); + + let eventIndex = 0; + while (eventIndex < events.length) { + yield* maybeYieldForCooperativeWork(yieldState); + + const timestamp = events[eventIndex].timestamp; + if (timestamp > endTs) { + break; + } + + while ( + eventIndex < events.length && + events[eventIndex].timestamp === timestamp + ) { + const event = events[eventIndex]; + if (event.type === 'rate') { + currentRateByRateKey[event.rateKey] = event.rate; + } else { + const previousUnits = currentUnitsByWalletId[event.walletId] || 0; + const deltaUnits = event.units - previousUnits; + currentUnitsByWalletId[event.walletId] = event.units; + unitsByRateKey[event.rateKey] = + (unitsByRateKey[event.rateKey] || 0) + deltaUnits; + } + eventIndex++; + } + + if (timestamp >= endTs) { + continue; + } + + recordPoint( + { + timestamp, + totalFiatBalance: computeTotalFiatBalance(false), + }, + true, + ); + } + + recordPoint( + { + timestamp: endTs, + totalFiatBalance: computeTotalFiatBalance(true), + }, + false, + ); + + if (!minPoint || !maxPoint) { + return undefined; + } + + return { + min: minPoint, + max: maxPoint, + minExcludingEnd, + maxExcludingEnd, + }; +} + +type BuildPnlAnalysisSeriesArgs = { wallets: WalletForAnalysis[]; timeframe: PnlTimeframe; quoteCurrency: string; fiatRateSeriesCache: FiatRateSeriesCache; /** - * Optional current/spot rate overrides per coin (e.g. from app Rates / market stats). + * Optional current/spot rate overrides per rate key (e.g. from app Rates / market stats). * When provided, the final point in the series will use this rate. This helps * ensure % changes match the ExchangeRate screen which uses a "currentRate" override. */ - currentRatesByCoin?: Record; + currentRatesByRateKey?: Record; nowMs?: number; maxPoints?: number; -}): PnlAnalysisResult { + onHistoricalRateDependency?: (cacheKey: string) => void; +}; + +type BuildPnlAnalysisSeriesGeneratorOptions = { + yieldEveryPoints?: number; + yieldEveryExtremaIterations?: number; +}; + +const DEFAULT_ASYNC_YIELD_EVERY_POINTS = 4; +const DEFAULT_ASYNC_YIELD_EVERY_EXTREMA_ITERATIONS = 256; + +const yieldToEventLoop = (): Promise => { + return new Promise(resolve => { + const setImmediateFn = ( + globalThis as { + setImmediate?: (callback: () => void) => unknown; + } + ).setImmediate; + + if (typeof setImmediateFn === 'function') { + setImmediateFn(resolve); + return; + } + + setTimeout(resolve, 0); + }); +}; + +function* buildPnlAnalysisSeriesGenerator( + args: BuildPnlAnalysisSeriesArgs, + options?: BuildPnlAnalysisSeriesGeneratorOptions, +): Generator { + const yieldEveryPoints = + typeof options?.yieldEveryPoints === 'number' && + Number.isFinite(options.yieldEveryPoints) && + options.yieldEveryPoints > 0 + ? Math.floor(options.yieldEveryPoints) + : 0; + const yieldEveryExtremaIterations = + typeof options?.yieldEveryExtremaIterations === 'number' && + Number.isFinite(options.yieldEveryExtremaIterations) && + options.yieldEveryExtremaIterations > 0 + ? Math.floor(options.yieldEveryExtremaIterations) + : 0; const nowMs = typeof args.nowMs === 'number' ? args.nowMs : Date.now(); const maxPoints = typeof args.maxPoints === 'number' ? args.maxPoints : 91; const wallets = args.wallets.slice(); const quoteCurrency = args.quoteCurrency.toUpperCase(); - const coins = Array.from( - new Set( - wallets.map(w => normalizeFiatRateSeriesCoin(w.currencyAbbreviation)), - ), - ).sort((a, b) => a.localeCompare(b)); + const rateIdentitiesByKey = new Map(); + const rateIdentityByWalletId = new Map(); + for (const wallet of wallets) { + const rateIdentity = getWalletRateIdentity(wallet); + if (!rateIdentity.key) { + continue; + } + rateIdentityByWalletId.set(wallet.walletId, rateIdentity); + if (!rateIdentitiesByKey.has(rateIdentity.key)) { + rateIdentitiesByKey.set(rateIdentity.key, rateIdentity); + } + } + + const rateKeys = Array.from(rateIdentitiesByKey.keys()).sort((a, b) => + a.localeCompare(b), + ); - if (coins.length === 0) { + if (rateKeys.length === 0) { return { timeframe: args.timeframe, quoteCurrency, - driverCoin: '', - coins: [], + driverRateKey: '', + rateKeys: [], wallets, points: [], assetSummaries: [], @@ -434,43 +966,44 @@ export function buildPnlAnalysisSeries(args: { }; } - // Driver coin: longest series wins; tie-break alphabetically. - let driverCoin = coins[0]; + // Driver rate key: longest series wins; tie-break alphabetically. + let driverRateKey = rateKeys[0]; let driverLen = -1; const baselineMs = getBaselineMs(args.timeframe, nowMs); - const firstNonZeroMs = - args.timeframe === 'ALL' ? findFirstNonZeroBalanceTs(wallets) : null; + const oldestSnapshotMs = + args.timeframe === 'ALL' ? findOldestSnapshotTs(wallets) : null; + const newestSnapshotMs = findNewestSnapshotTs(wallets); // ExchangeRate screen uses ALL series for 3M/1Y/5Y timeframes. Match that behavior // so percent changes are consistent across the app. - const seriesInterval: FiatRateInterval = (() => { - switch (args.timeframe) { - case '3M': - case '1Y': - case '5Y': - return 'ALL'; - default: - return args.timeframe; - } - })(); + const seriesInterval = getFiatTimeframeSeriesInterval(args.timeframe); - // Build compact rate series per coin and compute a strict overlapping window. + // Build compact rate series per rate key and compute the latest shared end bound. // // This avoids the heavy allocation work performed by alignTimestamps/trimTimestamps, - // which becomes especially expensive for ALL when multiple coins have long daily histories. - const rateSeriesByCoin: Record = {}; + // which becomes especially expensive for ALL when multiple assets have long daily histories. + // + // For bounded timeframes, keep one sample before the requested baseline so the + // first rendered point can still anchor at the exact timeframe start instead of + // jumping forward to the first post-cutoff rate sample. + const rateSeriesByRateKey: Record = {}; let overlapStart = Number.NEGATIVE_INFINITY; let overlapEnd = Number.POSITIVE_INFINITY; - for (const coin of coins) { + for (const rateKey of rateKeys) { + const rateIdentity = rateIdentitiesByKey.get(rateKey); + if (!rateIdentity) { + continue; + } const raw = getRatePointsFromCache({ fiatRateSeriesCache: args.fiatRateSeriesCache, quoteCurrency, - coin, + rateIdentity, seriesInterval, timeframe: args.timeframe, + onHistoricalRateDependency: args.onHistoricalRateDependency, }); const series = buildRateSeries( @@ -479,17 +1012,17 @@ export function buildPnlAnalysisSeries(args: { ); if (!series.ts.length) { throw new Error( - `Rates exist but no usable points after filtering for ${quoteCurrency}:${coin}:${args.timeframe}.`, + `Rates exist but no usable points after filtering for ${quoteCurrency}:${rateKey}:${args.timeframe}.`, ); } - rateSeriesByCoin[coin] = series; + rateSeriesByRateKey[rateKey] = series; if ( series.ts.length > driverLen || - (series.ts.length === driverLen && coin < driverCoin) + (series.ts.length === driverLen && rateKey < driverRateKey) ) { - driverCoin = coin; + driverRateKey = rateKey; driverLen = series.ts.length; } @@ -502,75 +1035,59 @@ export function buildPnlAnalysisSeries(args: { !Number.isFinite(overlapEnd) || overlapEnd < overlapStart ) { - throw new Error('No overlapping rate window found across selected coins.'); + throw new Error( + 'No overlapping rate window found across selected historical rate keys.', + ); } const desiredStart = args.timeframe === 'ALL' - ? firstNonZeroMs ?? overlapStart + ? oldestSnapshotMs ?? overlapStart : baselineMs ?? overlapStart; - const startBound = Math.max(overlapStart, desiredStart); + // Requested windows should keep their full start bound. Wallets that do not + // exist yet already contribute zero until their first snapshot, so we do not + // need to crop the chart to the shortest shared asset history. + const startBound = desiredStart; const endBound = overlapEnd; + if (!Number.isFinite(startBound) || endBound < startBound) { + throw new Error( + `No usable rate window found for ${quoteCurrency}:${args.timeframe}.`, + ); + } + // Always emit exactly maxPoints points (RN graph interpolation expects stable point count). const timeline = buildEvenTimeline(startBound, endBound, maxPoints); if (!timeline.length) throw new Error('Failed to build an analysis timeline.'); + if ( + newestSnapshotMs !== null && + newestSnapshotMs > endBound && + nowMs > timeline[timeline.length - 1] + ) { + timeline[timeline.length - 1] = nowMs; + } // Nearest-rate cursors, sampled on the shared timeline. - const rateCursorByCoin: Record = {}; - for (const coin of coins) { - rateCursorByCoin[coin] = makeNearestRateCursor(rateSeriesByCoin[coin]); + const rateCursorByRateKey: Record = {}; + for (const rateKey of rateKeys) { + rateCursorByRateKey[rateKey] = pnlAnalysisInternals.makeNearestRateCursor( + rateSeriesByRateKey[rateKey], + ); } - const getOverrideRate = (coin: string): number | undefined => { - const overrides = args.currentRatesByCoin; + const getOverrideRate = (rateKey: string): number | undefined => { + const overrides = args.currentRatesByRateKey; if (!overrides) return undefined; - const v = overrides[coin]; + const v = overrides[rateKey]; return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined; }; - const getLinearRateAtTs = ( - series: RateSeries, - tsMs: number, - ): number | undefined => { - const ts = series.ts; - const rate = series.rate; - const len = ts.length; - if (!len) return undefined; - - // Find first index i such that ts[i] >= tsMs. - let lo = 0; - let hi = len - 1; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (ts[mid] < tsMs) lo = mid + 1; - else hi = mid; - } - - const rightIdx = lo; - const rightTs = ts[rightIdx]; - const rightRate = rate[rightIdx]; - const leftIdx = rightIdx > 0 ? rightIdx - 1 : rightIdx; - const leftTs = ts[leftIdx]; - const leftRate = rate[leftIdx]; - - if (rightIdx === 0) return rightRate; - if (rightIdx === len - 1 && tsMs >= rightTs) return rightRate; - if (rightTs === leftTs) return rightRate; - if (tsMs <= leftTs) return leftRate; - if (tsMs >= rightTs) return rightRate; - - const ratio = (tsMs - leftTs) / (rightTs - leftTs); - const out = leftRate + (rightRate - leftRate) * ratio; - return Number.isFinite(out) ? out : undefined; - }; - // Windowed cost basis state (reset to value at interval start). // We iterate forward through snapshots during timeline generation so this is O(points + txs). type WindowBasisState = { walletId: string; - coin: string; + rateKey: string; decimals: number; snapshots: BalanceSnapshotStored[]; nextIdx: number; // next snapshot index to process (> startTs) @@ -583,16 +1100,17 @@ export function buildPnlAnalysisSeries(args: { const singleAsset = isSingleAsset(wallets); const points: PnlAnalysisPoint[] = []; - const baselineRateByCoin: Record = {}; - for (const coin of coins) { - const series = rateSeriesByCoin[coin]; - const r0 = getLinearRateAtTs(series, timeline[0]); + const baselineRateByRateKey: Record = {}; + for (const rateKey of rateKeys) { + // Keep the baseline anchored to the same sampled rate used by the first + // rendered point so the chart always starts at exactly 0 PnL / 0%. + const r0 = rateCursorByRateKey[rateKey]?.getNearest(timeline[0]); if (r0 === undefined) { throw new Error( - `Missing ${quoteCurrency}:${coin} rate at ts=${timeline[0]}.`, + `Missing ${quoteCurrency}:${rateKey} rate at ts=${timeline[0]}.`, ); } - baselineRateByCoin[coin] = r0; + baselineRateByRateKey[rateKey] = r0; } const startTs = timeline[0]; @@ -600,7 +1118,10 @@ export function buildPnlAnalysisSeries(args: { const windowStateByWalletId: Record = {}; for (const w of wallets) { - const coin = normalizeFiatRateSeriesCoin(w.currencyAbbreviation); + const rateIdentity = rateIdentityByWalletId.get(w.walletId); + if (!rateIdentity?.key) { + continue; + } const decimals = getAtomicDecimals(w.credentials); const snaps = w.snapshots; @@ -608,12 +1129,12 @@ export function buildPnlAnalysisSeries(args: { const unitsAtomic = lastIdx >= 0 ? parseAtomicToBigint(snaps[lastIdx].cryptoBalance) : 0n; const unitsNumber = atomicToUnitNumber(unitsAtomic, decimals); - const startRate = baselineRateByCoin[coin]; + const startRate = baselineRateByRateKey[rateIdentity.key]; const basisFiat = unitsNumber * startRate; windowStateByWalletId[w.walletId] = { walletId: w.walletId, - coin, + rateKey: rateIdentity.key, decimals, snapshots: snaps, nextIdx: findFirstSnapshotIndexAfter(snaps, startTs), @@ -625,7 +1146,26 @@ export function buildPnlAnalysisSeries(args: { } for (let i = 0; i < timeline.length; i++) { + if (yieldEveryPoints > 0 && i > 0 && i % yieldEveryPoints === 0) { + yield; + } + const ts = timeline[i]; + const isLastTimelinePoint = i === timeline.length - 1; + const rateAtTsByRateKey: Record = {}; + + for (const rateKey of rateKeys) { + const rate = isLastTimelinePoint + ? getOverrideRate(rateKey) ?? + rateCursorByRateKey[rateKey]?.getNearest(ts) + : rateCursorByRateKey[rateKey]?.getNearest(ts); + if (rate === undefined) { + throw new Error( + `Missing ${quoteCurrency}:${rateKey} rate at ts=${ts}.`, + ); + } + rateAtTsByRateKey[rateKey] = rate; + } const byWalletId: Record = {}; let totalFiatBalance = 0; @@ -634,28 +1174,16 @@ export function buildPnlAnalysisSeries(args: { let totalCryptoAtomic: bigint = 0n; let totalCryptoCreds: WalletCredentials | null = null; - // Determine markRate based on driver coin. - const driverRate = - i === timeline.length - 1 - ? getOverrideRate(driverCoin) ?? - rateCursorByCoin[driverCoin]?.getNearest(ts) - : rateCursorByCoin[driverCoin]?.getNearest(ts); - if (driverRate === undefined) { - throw new Error( - `Missing ${quoteCurrency}:${driverCoin} rate at ts=${ts}.`, - ); - } + // Determine markRate based on the driver rate key. + const driverRate = rateAtTsByRateKey[driverRateKey]; for (const w of wallets) { const st = windowStateByWalletId[w.walletId]; - const coin = st.coin; - const rate = - i === timeline.length - 1 - ? getOverrideRate(coin) ?? rateCursorByCoin[coin]?.getNearest(ts) - : rateCursorByCoin[coin]?.getNearest(ts); - if (rate === undefined) { - throw new Error(`Missing ${quoteCurrency}:${coin} rate at ts=${ts}.`); + if (!st) { + continue; } + const rateKey = st.rateKey; + const rate = rateAtTsByRateKey[rateKey]; // Advance window basis state by processing all snapshots up to this timestamp. while (st.nextIdx < st.snapshots.length) { @@ -669,7 +1197,7 @@ export function buildPnlAnalysisSeries(args: { if (delta > 0n) { let markRate = Number((s as any).markRate); if (!Number.isFinite(markRate) || markRate <= 0) { - const fallback = rateCursorByCoin[coin]?.getNearest(sTs); + const fallback = rateCursorByRateKey[rateKey]?.getNearest(sTs); markRate = fallback === undefined ? rate : fallback; } const deltaUnits = atomicToUnitNumber(delta, st.decimals); @@ -728,7 +1256,7 @@ export function buildPnlAnalysisSeries(args: { const pnlPercent = costBasis > 0 ? (unrealizedPnlFiat / costBasis) * 100 : 0; - const base = baselineRateByCoin[coin] || rate; + const base = baselineRateByRateKey[rateKey] || rate; const walletRatePct = base > 0 ? ((rate - base) / base) * 100 : 0; byWalletId[w.walletId] = { @@ -758,7 +1286,7 @@ export function buildPnlAnalysisSeries(args: { ? (totalUnrealizedPnlFiat / totalRemainingCostBasisFiat) * 100 : 0; - const driverBase = baselineRateByCoin[driverCoin] || driverRate; + const driverBase = baselineRateByRateKey[driverRateKey] || driverRate; const ratePercentChange = driverBase > 0 ? ((driverRate - driverBase) / driverBase) * 100 @@ -786,20 +1314,30 @@ export function buildPnlAnalysisSeries(args: { }); } + const exactExtrema = yield* buildExactTotalFiatBalanceExtremaGenerator({ + wallets, + rateKeys, + rateIdentityByWalletId, + rateSeriesByRateKey, + startTs, + endTs, + getOverrideRate, + yieldEveryIterations: yieldEveryExtremaIterations, + }); + // Summaries const first = points[0]; const last = points[points.length - 1]; - const assetSummaries: AssetPnlSummary[] = coins.map(coin => { + const assetSummaries: AssetPnlSummary[] = rateKeys.map(rateKey => { + const rateIdentity = rateIdentitiesByKey.get(rateKey); const ids = new Set( wallets - .filter( - w => normalizeFiatRateSeriesCoin(w.currencyAbbreviation) === coin, - ) + .filter(w => rateIdentityByWalletId.get(w.walletId)?.key === rateKey) .map(w => w.walletId), ); - // Sum windowed PnL + basis for wallets in this coin group. + // Sum windowed PnL + basis for wallets in this rate-key group. let startPnl = 0; let endPnl = 0; let endBasis = 0; @@ -811,18 +1349,20 @@ export function buildPnlAnalysisSeries(args: { endBasis += last.byWalletId[w.walletId]?.remainingCostBasisFiat ?? 0; } - const rateStart = baselineRateByCoin[coin]; - const rateEnd = rateCursorByCoin[coin]?.getNearest(endTs); + const rateStart = baselineRateByRateKey[rateKey]; + const rateEnd = rateCursorByRateKey[rateKey]?.getNearest(endTs); if (rateEnd === undefined) - throw new Error(`Missing ${quoteCurrency}:${coin} rate at ts=${endTs}.`); + throw new Error( + `Missing ${quoteCurrency}:${rateKey} rate at ts=${endTs}.`, + ); const rateChange = rateEnd - rateStart; const ratePct = rateStart > 0 ? (rateChange / rateStart) * 100 : 0; const pnlPercent = endBasis > 0 ? (endPnl / endBasis) * 100 : 0; return { - coin, - displaySymbol: coin.toUpperCase(), + rateKey, + displaySymbol: rateIdentity?.displaySymbol || rateKey.toUpperCase(), rateStart, rateEnd, rateChange, @@ -844,11 +1384,62 @@ export function buildPnlAnalysisSeries(args: { return { timeframe: args.timeframe, quoteCurrency, - driverCoin, - coins, + driverRateKey, + rateKeys, wallets, points, + exactExtrema, assetSummaries, totalSummary, }; } + +export function buildPnlAnalysisSeries( + args: BuildPnlAnalysisSeriesArgs, +): PnlAnalysisResult { + const generator = buildPnlAnalysisSeriesGenerator(args); + let next = generator.next(); + while (!next.done) { + next = generator.next(); + } + return next.value; +} + +export async function buildPnlAnalysisSeriesAsync( + args: BuildPnlAnalysisSeriesArgs & { + signal?: AbortSignal; + yieldEveryPoints?: number; + yieldEveryExtremaIterations?: number; + yieldControl?: () => Promise; + }, +): Promise { + const { + signal, + yieldEveryPoints, + yieldEveryExtremaIterations, + yieldControl, + ...rest + } = args; + const generator = buildPnlAnalysisSeriesGenerator(rest, { + yieldEveryPoints: + typeof yieldEveryPoints === 'number' + ? yieldEveryPoints + : DEFAULT_ASYNC_YIELD_EVERY_POINTS, + yieldEveryExtremaIterations: + typeof yieldEveryExtremaIterations === 'number' + ? yieldEveryExtremaIterations + : DEFAULT_ASYNC_YIELD_EVERY_EXTREMA_ITERATIONS, + }); + const yieldFn = yieldControl || yieldToEventLoop; + + throwIfAbortSignalAborted(signal); + + let next = generator.next(); + while (!next.done) { + throwIfAbortSignalAborted(signal); + await yieldFn(); + throwIfAbortSignalAborted(signal); + next = generator.next(); + } + return next.value; +} diff --git a/src/utils/portfolio/core/pnl/rates.ts b/src/utils/portfolio/core/pnl/rates.ts index 4ffc10ded8..837d4819f3 100644 --- a/src/utils/portfolio/core/pnl/rates.ts +++ b/src/utils/portfolio/core/pnl/rates.ts @@ -2,8 +2,12 @@ import type { FiatRatePoint, FiatRateSeriesCache, FiatRateInterval, + FiatRateSeriesReaderIdentity, +} from '../fiatRateSeries'; +import { + getFiatRateSeriesCacheKey, + normalizeFiatRateSeriesCoin, } from '../fiatRateSeries'; -import {getFiatRateSeriesCacheKey} from '../fiatRateSeries'; import { PREF_1D, PREF_1W, @@ -14,17 +18,7 @@ import { PREF_ALL, } from './intervalPrefs'; -export const normalizeFiatRateSeriesCoin = ( - currencyAbbreviation?: string, -): string => { - switch ((currencyAbbreviation || '').toLowerCase()) { - case 'matic': - case 'pol': - return 'pol'; - default: - return (currencyAbbreviation || '').toLowerCase(); - } -}; +export {normalizeFiatRateSeriesCoin}; type Finder = (targetTs: number) => FiatRatePoint | null; @@ -113,17 +107,34 @@ export const createFiatRateLookup = (args: { cache: FiatRateSeriesCache; nowMs: number; bridgeQuoteCurrency?: string; + chain?: string; + tokenAddress?: string; }): FiatRateLookup => { - const {quoteCurrency, coin, cache, nowMs, bridgeQuoteCurrency} = args; + const { + quoteCurrency, + coin, + cache, + nowMs, + bridgeQuoteCurrency, + chain, + tokenAddress, + } = args; + const identity: FiatRateSeriesReaderIdentity | undefined = + chain || tokenAddress + ? { + chain, + tokenAddress, + } + : undefined; const keyByInterval: Record = { - '1D': getFiatRateSeriesCacheKey(quoteCurrency, coin, '1D'), - '1W': getFiatRateSeriesCacheKey(quoteCurrency, coin, '1W'), - '1M': getFiatRateSeriesCacheKey(quoteCurrency, coin, '1M'), - '3M': getFiatRateSeriesCacheKey(quoteCurrency, coin, '3M'), - '1Y': getFiatRateSeriesCacheKey(quoteCurrency, coin, '1Y'), - '5Y': getFiatRateSeriesCacheKey(quoteCurrency, coin, '5Y'), - ALL: getFiatRateSeriesCacheKey(quoteCurrency, coin, 'ALL'), + '1D': getFiatRateSeriesCacheKey(quoteCurrency, coin, '1D', identity), + '1W': getFiatRateSeriesCacheKey(quoteCurrency, coin, '1W', identity), + '1M': getFiatRateSeriesCacheKey(quoteCurrency, coin, '1M', identity), + '3M': getFiatRateSeriesCacheKey(quoteCurrency, coin, '3M', identity), + '1Y': getFiatRateSeriesCacheKey(quoteCurrency, coin, '1Y', identity), + '5Y': getFiatRateSeriesCacheKey(quoteCurrency, coin, '5Y', identity), + ALL: getFiatRateSeriesCacheKey(quoteCurrency, coin, 'ALL', identity), }; const findersByInterval = new Map(); @@ -171,6 +182,8 @@ export const createFiatRateLookup = (args: { coin, cache, nowMs, + chain, + tokenAddress, }); } return bridgeCoinLookup; diff --git a/src/utils/portfolio/core/pnl/snapshots.ts b/src/utils/portfolio/core/pnl/snapshots.ts index 583f2b627b..83664e4917 100644 --- a/src/utils/portfolio/core/pnl/snapshots.ts +++ b/src/utils/portfolio/core/pnl/snapshots.ts @@ -5,6 +5,7 @@ import { parseAtomicToBigint, } from '../format'; import type {FiatRateSeriesCache} from '../fiatRateSeries'; +import {normalizeFiatRateSeriesTokenAddress} from '../fiatRateSeries'; import {createFiatRateLookup, normalizeFiatRateSeriesCoin} from './rates'; import {atomicToUnitNumber} from './atomic'; import type { @@ -50,8 +51,12 @@ export const getAssetIdFromWallet = ( ): string => { const chain = (wallet.chain || '').toLowerCase(); const coin = (wallet.currencyAbbreviation || '').toLowerCase(); - if (wallet.tokenAddress) { - return `${chain}:${coin}:${wallet.tokenAddress.toLowerCase()}`; + const normalizedTokenAddress = normalizeFiatRateSeriesTokenAddress( + chain, + wallet.tokenAddress, + ); + if (normalizedTokenAddress) { + return `${chain}:${coin}:${normalizedTokenAddress}`; } return `${chain}:${coin}`; }; @@ -1031,6 +1036,10 @@ const createSimulationSetup = ( coin: rateCoin, cache: fiatRateSeriesCache, nowMs, + chain: args.wallet.tokenAddress + ? String(args.wallet.chain || '') + : undefined, + tokenAddress: args.wallet.tokenAddress, }); const feePaidByWallet = (tx: NormalizedTx): boolean => diff --git a/src/utils/portfolio/rate.ts b/src/utils/portfolio/rate.ts index 51a54edaeb..c601d0a4f5 100644 --- a/src/utils/portfolio/rate.ts +++ b/src/utils/portfolio/rate.ts @@ -3,10 +3,15 @@ import type { FiatRateInterval, FiatRatePoint, FiatRateSeriesCache, + FiatRateSeriesReaderIdentity, } from '../../store/rate/rate.models'; import {getFiatRateSeriesCacheKey} from '../../store/rate/rate.models'; import {normalizeFiatRateSeriesCoin} from './core/pnl/rates'; import {getLastDayTimestampStartOfHourMs} from '../helper-methods'; +import { + getFiatTimeframeSeriesInterval, + getFiatTimeframeWindowMs, +} from '../fiatTimeframes'; export type RatePoint = { ts: number; @@ -32,6 +37,7 @@ const getFiatRateSeriesPoints = (args: { fiatCode: string; currencyAbbreviation: string; interval: FiatRateInterval; + identity?: FiatRateSeriesReaderIdentity; }): FiatRatePoint[] | undefined => { const cache = args.fiatRateSeriesCache; if (!cache) { @@ -43,6 +49,7 @@ const getFiatRateSeriesPoints = (args: { args.fiatCode, coin, args.interval, + args.identity, ); const series = cache[cacheKey]; const points = Array.isArray(series?.points) ? series.points : []; @@ -130,12 +137,14 @@ export const getFiatRateFromSeriesCacheAtTimestamp = (args: { interval: FiatRateInterval; timestampMs: number; method?: 'nearest' | 'linear'; + identity?: FiatRateSeriesReaderIdentity; }): number | undefined => { const points = getFiatRateSeriesPoints({ fiatRateSeriesCache: args.fiatRateSeriesCache, fiatCode: args.fiatCode, currencyAbbreviation: args.currencyAbbreviation, interval: args.interval, + identity: args.identity, }); if (!points) { return undefined; @@ -150,28 +159,11 @@ export const getFiatRateFromSeriesCacheAtTimestamp = (args: { }; const MS_PER_HOUR = 60 * 60 * 1000; -const MS_PER_DAY = 24 * MS_PER_HOUR; export const getWindowMsForFiatRateTimeframe = ( timeframe: FiatRateInterval, -): number => { - switch (timeframe) { - case '1D': - return 1 * MS_PER_DAY; - case '1W': - return 7 * MS_PER_DAY; - case '1M': - return 30 * MS_PER_DAY; - case '3M': - return 90 * MS_PER_DAY; - case '1Y': - return 365 * MS_PER_DAY; - case '5Y': - return 1825 * MS_PER_DAY; - case 'ALL': - default: - return 0; - } +): number | undefined => { + return getFiatTimeframeWindowMs(timeframe); }; const roundDownToHourMs = (tsMs: number): number => { @@ -193,7 +185,7 @@ export const getFiatRateBaselineTsForTimeframe = (args: { } const windowMs = getWindowMsForFiatRateTimeframe(args.timeframe); - if (!windowMs) { + if (typeof windowMs !== 'number') { return undefined; } @@ -203,20 +195,13 @@ export const getFiatRateBaselineTsForTimeframe = (args: { export const getFiatRateSeriesIntervalForTimeframe = ( timeframe: FiatRateInterval, ): CachedFiatRateInterval => { - switch (timeframe) { - case '3M': - case '1Y': - case '5Y': - return 'ALL'; - default: - return timeframe; - } + return getFiatTimeframeSeriesInterval(timeframe); }; export type FiatRateTimeframeConfig = { - windowMs: number; + windowMs?: number; baselineTimestampMs?: number; - seriesInterval: FiatRateInterval; + seriesInterval: CachedFiatRateInterval; }; export const getFiatRateTimeframeConfig = (args: { @@ -252,6 +237,7 @@ export const getFiatRateChangeForTimeframe = (args: { nowMs?: number; currentRate?: number; method?: 'nearest' | 'linear'; + identity?: FiatRateSeriesReaderIdentity; }): FiatRateChangeForTimeframe | undefined => { const cache = args.fiatRateSeriesCache; if (!cache) { @@ -270,6 +256,7 @@ export const getFiatRateChangeForTimeframe = (args: { fiatCode: args.fiatCode, currencyAbbreviation: args.currencyAbbreviation, interval: seriesInterval, + identity: args.identity, }); if (!points) { return undefined; @@ -285,6 +272,7 @@ export const getFiatRateChangeForTimeframe = (args: { interval: seriesInterval, timestampMs: nowMs, method: 'nearest', + identity: args.identity, }); if (!(typeof currentRate === 'number' && Number.isFinite(currentRate))) { return undefined; @@ -310,6 +298,7 @@ export const getFiatRateChangeForTimeframe = (args: { interval: seriesInterval, timestampMs: baselineTimestampMs, method, + identity: args.identity, }); if (!(typeof baselineRate === 'number' && Number.isFinite(baselineRate))) { return undefined; diff --git a/src/utils/scheduleAfterInteractionsAndFrames.ts b/src/utils/scheduleAfterInteractionsAndFrames.ts new file mode 100644 index 0000000000..a4f22b6ec6 --- /dev/null +++ b/src/utils/scheduleAfterInteractionsAndFrames.ts @@ -0,0 +1,167 @@ +import {InteractionManager} from 'react-native'; +import {isAbortError} from './abort'; + +const DEFAULT_SCHEDULE_AFTER_INTERACTIONS_FALLBACK_MS = 700; + +export type ScheduledAfterInteractionsHandle = { + cancel: () => void; + done: Promise; + signal: AbortSignal; +}; + +export const scheduleAfterInteractionsAndFrames = (args: { + callback: (signal: AbortSignal) => void | Promise; + fallbackMs?: number; + onError?: (error: unknown) => void; +}): ScheduledAfterInteractionsHandle => { + const controller = new AbortController(); + let didRun = false; + let timeout: ReturnType | undefined; + let fallbackTimeout: ReturnType | undefined; + let firstFrame: number | undefined; + let secondFrame: number | undefined; + let resolveDone: (() => void) | undefined; + + const done = new Promise(resolve => { + resolveDone = resolve; + }); + + const finish = () => { + if (!resolveDone) { + return; + } + + resolveDone(); + resolveDone = undefined; + }; + + const clearScheduledTimers = () => { + if (fallbackTimeout) { + clearTimeout(fallbackTimeout); + fallbackTimeout = undefined; + } + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + }; + + const clearScheduledFrames = () => { + if (typeof cancelAnimationFrame !== 'function') { + return; + } + + if (typeof firstFrame === 'number') { + cancelAnimationFrame(firstFrame); + firstFrame = undefined; + } + if (typeof secondFrame === 'number') { + cancelAnimationFrame(secondFrame); + secondFrame = undefined; + } + }; + + const finishIfCancelled = () => { + if (!controller.signal.aborted) { + return false; + } + + finish(); + return true; + }; + + const shouldSkipScheduling = () => { + if (finishIfCancelled()) { + return true; + } + + return didRun; + }; + + const reportError = (error: unknown) => { + if (controller.signal.aborted || isAbortError(error)) { + return; + } + + try { + args.onError?.(error); + } catch { + // Secondary error handlers should not break the callback lifecycle. + } + }; + + const executeCallback = () => { + timeout = undefined; + + if (finishIfCancelled()) { + return; + } + + Promise.resolve() + .then(() => args.callback(controller.signal)) + .catch(reportError) + .finally(finish); + }; + + const runCallback = () => { + if (shouldSkipScheduling()) { + return; + } + + didRun = true; + clearScheduledTimers(); + timeout = setTimeout(executeCallback, 0); + }; + + const task = InteractionManager.runAfterInteractions(() => { + if (shouldSkipScheduling()) { + return; + } + + if (typeof requestAnimationFrame === 'function') { + firstFrame = requestAnimationFrame(() => { + firstFrame = undefined; + + if (shouldSkipScheduling()) { + return; + } + + secondFrame = requestAnimationFrame(() => { + secondFrame = undefined; + runCallback(); + }); + }); + return; + } + + runCallback(); + }); + + if (!controller.signal.aborted && !didRun) { + fallbackTimeout = setTimeout( + runCallback, + Math.max( + 0, + Math.floor( + args.fallbackMs ?? DEFAULT_SCHEDULE_AFTER_INTERACTIONS_FALLBACK_MS, + ), + ), + ); + } + + return { + cancel: () => { + if (controller.signal.aborted) { + return; + } + + controller.abort(); + task.cancel(); + clearScheduledTimers(); + clearScheduledFrames(); + finish(); + }, + done, + signal: controller.signal, + }; +}; From 2073aa6dbc3c2d53c4bdb21634f35bc9da9d0a38 Mon Sep 17 00:00:00 2001 From: Marty Alcala Date: Tue, 17 Mar 2026 22:24:03 -0400 Subject: [PATCH 012/138] fix: hide balance charts for testnet wallets and use smaller font size for large balances --- src/components/charts/BalanceHistoryChart.tsx | 56 ++++++++++--------- .../tabs/home/components/PortfolioBalance.tsx | 4 +- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/components/charts/BalanceHistoryChart.tsx b/src/components/charts/BalanceHistoryChart.tsx index 5355a84714..5465fc9fa8 100644 --- a/src/components/charts/BalanceHistoryChart.tsx +++ b/src/components/charts/BalanceHistoryChart.tsx @@ -292,34 +292,38 @@ const BalanceHistoryChart = ({ const scopedSnapshotsVersionRef = useRef(undefined); const fiatRateSeriesCacheRef = useRef(fiatRateSeriesCache); - const {hasAnySnapshots, hasAnyChartableSnapshots} = useMemo(() => { - let totalSnapshotCount = 0; - let totalChartableSnapshotCount = 0; + const {hasAnySnapshots, hasAnyChartableSnapshots, hasAnyMainnetWallet} = + useMemo(() => { + let totalSnapshotCount = 0; + let totalChartableSnapshotCount = 0; + let anyMainnetWallet = false; + + for (const wallet of wallets || []) { + const walletId = getPortfolioWalletId(wallet); + if (!walletId) { + continue; + } - for (const wallet of wallets || []) { - const walletId = getPortfolioWalletId(wallet); - if (!walletId) { - continue; - } + const snapshotCount = getPortfolioWalletSnapshots( + snapshotsByWalletId, + walletId, + ).length; + totalSnapshotCount += snapshotCount; - const snapshotCount = getPortfolioWalletSnapshots( - snapshotsByWalletId, - walletId, - ).length; - totalSnapshotCount += snapshotCount; + if (!isPortfolioWalletOnMainnet(wallet)) { + continue; + } - if (!isPortfolioWalletOnMainnet(wallet)) { - continue; + anyMainnetWallet = true; + totalChartableSnapshotCount += snapshotCount; } - totalChartableSnapshotCount += snapshotCount; - } - - return { - hasAnySnapshots: totalSnapshotCount > 0, - hasAnyChartableSnapshots: totalChartableSnapshotCount > 0, - }; - }, [snapshotsByWalletId, wallets]); + return { + hasAnySnapshots: totalSnapshotCount > 0, + hasAnyChartableSnapshots: totalChartableSnapshotCount > 0, + hasAnyMainnetWallet: anyMainnetWallet, + }; + }, [snapshotsByWalletId, wallets]); const getPrepScopedDepIdentityId = useCallback((value: object): number => { const existingId = prepScopedDepIdentityIdsRef.current.get(value); @@ -1363,10 +1367,10 @@ const BalanceHistoryChart = ({ // If there are no chartable snapshots, hide chart UI but still allow callers // to render any pre-chart badges/content. Preserve the legacy skeleton only - // when there are truly no snapshots yet; non-mainnet-only snapshots should - // not show a perpetual loading placeholder. + // when a mainnet wallet in scope is still waiting for its first snapshots; + // testnet-only scopes should stay hidden even before snapshots exist. if (!hasAnyChartableSnapshots) { - if (showLoaderWhenNoSnapshots && !hasAnySnapshots) { + if (showLoaderWhenNoSnapshots && hasAnyMainnetWallet && !hasAnySnapshots) { return ( <> {preChartContent ? ( diff --git a/src/navigation/tabs/home/components/PortfolioBalance.tsx b/src/navigation/tabs/home/components/PortfolioBalance.tsx index 967b5c702a..1fca514629 100644 --- a/src/navigation/tabs/home/components/PortfolioBalance.tsx +++ b/src/navigation/tabs/home/components/PortfolioBalance.tsx @@ -83,9 +83,9 @@ const PortfolioBalanceTitle = styled(BaseText)` `; const PortfolioBalanceText = styled(BaseText)<{$isCompact?: boolean}>` - font-size: ${({$isCompact}) => ($isCompact ? '28px' : '39px')}; + font-size: ${({$isCompact}) => ($isCompact ? '26px' : '39px')}; font-weight: 700; - line-height: ${({$isCompact}) => ($isCompact ? '40px' : '59px')}; + line-height: ${({$isCompact}) => ($isCompact ? '38px' : '59px')}; color: ${({theme}) => theme.colors.text}; margin: 2px 0; `; From 1390a2b0d42644d35f3103e36447605d8fda8cfb Mon Sep 17 00:00:00 2001 From: Marty Alcala Date: Sun, 22 Mar 2026 14:37:41 -0400 Subject: [PATCH 013/138] fix: prevent duplicate wallets in asset list --- .../tabs/home/hooks/usePortfolioAssetRows.ts | 3 +- src/navigation/wallet/screens/KeyOverview.tsx | 133 +++--------------- src/utils/portfolio/assets.ts | 56 +++++++- 3 files changed, 73 insertions(+), 119 deletions(-) diff --git a/src/navigation/tabs/home/hooks/usePortfolioAssetRows.ts b/src/navigation/tabs/home/hooks/usePortfolioAssetRows.ts index b7b6401ad7..c07ffef77e 100644 --- a/src/navigation/tabs/home/hooks/usePortfolioAssetRows.ts +++ b/src/navigation/tabs/home/hooks/usePortfolioAssetRows.ts @@ -20,6 +20,7 @@ import { getDisplayAssetRowItems, getPopulateLoadingByAssetKey, getQuoteCurrency, + getVisibleWalletsForKey, getVisibleWalletsFromKeys, isFiatLoadingForWallets, } from '../../../../utils/portfolio/assets'; @@ -66,7 +67,7 @@ const usePortfolioAssetRows = ({gainLossMode, keyId}: Args): Result => { const wallets = useMemo(() => { if (keyId && keys[keyId]) { - return getVisibleWalletsFromKeys({[keyId]: keys[keyId]}); + return getVisibleWalletsForKey(keys[keyId]); } return getVisibleWalletsFromKeys(keys, homeCarouselConfig); }, [homeCarouselConfig, keyId, keys]); diff --git a/src/navigation/wallet/screens/KeyOverview.tsx b/src/navigation/wallet/screens/KeyOverview.tsx index 7d777b72d3..906c46468f 100644 --- a/src/navigation/wallet/screens/KeyOverview.tsx +++ b/src/navigation/wallet/screens/KeyOverview.tsx @@ -61,7 +61,6 @@ import { GhostWhite, LightBlack, NeutralSlate, - Slate, Slate30, SlateDark, White, @@ -80,7 +79,7 @@ import { } from '../components/ErrorMessages'; import OptionsSheet, {Option} from '../components/OptionsSheet'; import Icons from '../components/WalletIcons'; -import {WalletGroupParamList, WalletScreens} from '../WalletGroup'; +import {WalletGroupParamList} from '../WalletGroup'; import {useAppDispatch, useAppSelector, useLogger} from '../../../utils/hooks'; import SheetModal from '../../../components/modal/base/sheet/SheetModal'; import { @@ -141,14 +140,9 @@ import { import {isTSSKey} from '../../../store/wallet/effects/tss-send/tss-send'; import { buildPortfolioGainLossSummaryFromPortfolioSnapshots, - getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots, + getVisibleWalletsForKey, getQuoteCurrency, - hasSnapshotsBeforeMsForWallets, - hasSnapshotsForWallets, isPopulateLoadingForWallets, - getLegacyPercentageDifferenceFromTotals, - getKeyLastDayPercentageDifference, - getPercentageDifferenceFromPercentRatio, } from '../../../utils/portfolio/assets'; import {maybePopulatePortfolioForWallets} from '../../../store/portfolio'; @@ -194,10 +188,6 @@ const BalanceContainer = styled.View` align-items: center; `; -const PercentageWrapper = styled.View` - align-self: center; -`; - const WalletListHeader = styled.View` padding: 10px; margin-top: 10px; @@ -213,21 +203,6 @@ const WalletListFooterContainer = styled.View` gap: 12px; `; -const WalletListFooter = styled(TouchableOpacity)` - flex-direction: row; - align-items: center; - margin-bottom: 30px; - margin-top: -10px; -`; - -const WalletListFooterText = styled(BaseText)` - font-size: 16px; - font-style: normal; - font-weight: 400; - letter-spacing: 0; - margin-left: 10px; -`; - const AddWalletLinkContainer = styled.View` padding: 13px 0; align-items: center; @@ -517,7 +492,6 @@ const KeyOverview = () => { }, [dispatch, key?.id]); const { - wallets = [], totalBalance = 0, totalBalanceLastDay, } = useAppSelector(({WALLET}) => WALLET.keys[id]) || {}; @@ -529,10 +503,8 @@ const KeyOverview = () => { }, [dispatch, key, defaultAltCurrency.isoCode, rates, hideAllBalances]); const visibleKeyWallets = useMemo(() => { - return (key?.wallets ?? []).filter( - w => !w.hideWallet && !w.hideWalletByAccount, - ); - }, [key?.wallets]); + return getVisibleWalletsForKey(key); + }, [key]); const allocationWalletRows: AllocationWallet[] = useMemo(() => { return visibleKeyWallets.map((w: Wallet) => { @@ -560,12 +532,12 @@ const KeyOverview = () => { }); }, [defaultAltCurrency?.isoCode, portfolio.quoteCurrency]); - const keyWalletIdsSig = useMemo(() => { - return (key?.wallets || []) + const visibleKeyWalletIdsSig = useMemo(() => { + return visibleKeyWallets .map(w => w?.id) .filter((id): id is string => typeof id === 'string' && !!id) .join(','); - }, [key?.wallets]); + }, [visibleKeyWallets]); // If we try to populate portfolio snapshots while another populate pass is // already running, the thunk may no-op. Track a pending request so we can @@ -582,8 +554,8 @@ const KeyOverview = () => { pendingKeyBalanceChartRefreshRef.current = false; const latestKey = state.WALLET?.keys?.[id] as Key | undefined; - - if (!latestKey?.wallets?.length) { + const latestVisibleWallets = getVisibleWalletsForKey(latestKey); + if (!latestVisibleWallets.length) { return; } @@ -596,8 +568,9 @@ const KeyOverview = () => { maybePopulatePortfolioForWallets({ // IMPORTANT: re-read the latest Redux wallet objects after any // balance/rate refresh completes so chart snapshot population does not - // get stuck using stale wallet balances from the first render. - wallets: latestKey.wallets, + // get stuck using stale wallet balances from the first render. Keep the + // wallet scope aligned with the wallets visible in KeyOverview. + wallets: latestVisibleWallets, quoteCurrency: latestQuoteCurrency, }) as any, ); @@ -609,7 +582,12 @@ const KeyOverview = () => { } void maybeRefreshKeyBalanceChart(); - }, [isFocused, keyWalletIdsSig, maybeRefreshKeyBalanceChart, quoteCurrency]); + }, [ + isFocused, + maybeRefreshKeyBalanceChart, + quoteCurrency, + visibleKeyWalletIdsSig, + ]); useEffect(() => { if ( @@ -675,81 +653,6 @@ const KeyOverview = () => { visibleKeyWallets, ]); - const portfolioPercentageDifference = useMemo(() => { - const pnl = getPortfolioPnlChangeForTimeframeFromPortfolioSnapshots({ - snapshotsByWalletId: portfolio.snapshotsByWalletId || {}, - wallets: visibleKeyWallets, - quoteCurrency, - timeframe: '1D', - rates, - lastDayRates, - fiatRateSeriesCache, - }); - if (!pnl.available) { - return null; - } - return getPercentageDifferenceFromPercentRatio(pnl.percentRatio); - }, [ - fiatRateSeriesCache, - lastDayRates, - portfolio.snapshotsByWalletId, - quoteCurrency, - rates, - visibleKeyWallets, - ]); - - const legacyPercentageDifference = useMemo(() => { - return getLegacyPercentageDifferenceFromTotals({ - totalBalance, - totalBalanceLastDay, - }); - }, [totalBalance, totalBalanceLastDay]); - - const hasKeySnapshots = useMemo(() => { - return hasSnapshotsForWallets({ - snapshotsByWalletId: portfolio.snapshotsByWalletId || {}, - wallets: visibleKeyWallets, - }); - }, [portfolio.snapshotsByWalletId, visibleKeyWallets]); - - const hasKeySnapshotsBeforePopulateStarted = useMemo(() => { - const startedAt = portfolio.populateStatus?.startedAt; - if ( - !portfolio.populateStatus?.inProgress || - typeof startedAt !== 'number' - ) { - return true; - } - return hasSnapshotsBeforeMsForWallets({ - snapshotsByWalletId: portfolio.snapshotsByWalletId || {}, - wallets: visibleKeyWallets, - cutoffMs: startedAt, - }); - }, [ - portfolio.populateStatus?.inProgress, - portfolio.populateStatus?.startedAt, - portfolio.snapshotsByWalletId, - visibleKeyWallets, - ]); - - const percentageDifference = useMemo(() => { - return getKeyLastDayPercentageDifference({ - totalBalance, - hasSnapshots: hasKeySnapshots, - hasSnapshotsBeforePopulateStarted: hasKeySnapshotsBeforePopulateStarted, - isPopulateLoading: isKeyPopulateLoading, - legacyPercentageDifference, - portfolioPercentageDifference, - }); - }, [ - totalBalance, - hasKeySnapshots, - hasKeySnapshotsBeforePopulateStarted, - isKeyPopulateLoading, - legacyPercentageDifference, - portfolioPercentageDifference, - ]); - const allTimeGainLossText = useMemo(() => { if (!gainLossSummary.total.available) { return null; diff --git a/src/utils/portfolio/assets.ts b/src/utils/portfolio/assets.ts index e2c53c4cbc..4566bc9008 100644 --- a/src/utils/portfolio/assets.ts +++ b/src/utils/portfolio/assets.ts @@ -661,14 +661,64 @@ export const getVisibleKeysFromKeys = ( return all.filter(k => !hiddenKeyIds.has(k.id)); }; +const getKeyOverviewAccountVisibilityKey = ( + wallet: Wallet | undefined, +): string => { + let accountKey = toStringOrEmpty(wallet?.receiveAddress); + const isComplete = + typeof wallet?.credentials?.isComplete === 'function' + ? wallet.credentials.isComplete() + : true; + + if (!accountKey && (!isComplete || wallet?.pendingTssSession)) { + accountKey = toStringOrEmpty(wallet?.credentials?.walletId); + } + + return accountKey; +}; + +const isWalletVisibleInKeyOverview = ( + key: Key | undefined, + wallet: Wallet | undefined, +): boolean => { + if (!wallet || wallet.hideWallet) { + return false; + } + + const accountKey = getKeyOverviewAccountVisibilityKey(wallet); + if (accountKey && key?.evmAccountsInfo?.[accountKey]?.hideAccount) { + return false; + } + + return true; +}; + +export const getVisibleWalletsForKey = (key: Key | undefined): Wallet[] => { + const seenWalletIds = new Set(); + const uniqueWallets: Wallet[] = []; + + for (const wallet of key?.wallets || []) { + const walletId = + wallet?.id != null ? String(wallet.id) : '__missing_wallet_id__'; + if (seenWalletIds.has(walletId)) { + continue; + } + + seenWalletIds.add(walletId); + uniqueWallets.push(wallet); + } + + return uniqueWallets.filter(wallet => + isWalletVisibleInKeyOverview(key, wallet), + ); +}; + export const getVisibleWalletsFromKeys = ( keys: Record | undefined, homeCarouselConfig?: HomeCarouselConfig[] | undefined, ): Wallet[] => { const visibleKeys = getVisibleKeysFromKeys(keys, homeCarouselConfig); - return visibleKeys - .flatMap(k => (Array.isArray(k.wallets) ? k.wallets : [])) - .filter(w => !w.hideWallet && !w.hideWalletByAccount); + return visibleKeys.flatMap(getVisibleWalletsForKey); }; export const buildWalletIdsByAssetGroupKey = ( From 169b0628c3cb7d3c015f44d965a8dd9c2be8ed56 Mon Sep 17 00:00:00 2001 From: Marty Alcala Date: Sun, 22 Mar 2026 14:47:47 -0400 Subject: [PATCH 014/138] ref: make 1D timeframe the default --- src/components/charts/BalanceHistoryChart.tsx | 19 +++++++++++++++++-- .../tabs/home/components/PortfolioBalance.tsx | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/components/charts/BalanceHistoryChart.tsx b/src/components/charts/BalanceHistoryChart.tsx index 5465fc9fa8..68a21a417b 100644 --- a/src/components/charts/BalanceHistoryChart.tsx +++ b/src/components/charts/BalanceHistoryChart.tsx @@ -117,6 +117,15 @@ const PRECOMPUTE_TIMEFRAME_ORDER: FiatRateInterval[] = [ '1Y', '5Y', ]; +const BALANCE_CHART_TIMEFRAME_ORDER: FiatRateInterval[] = [ + '1D', + '1W', + '1M', + '3M', + '1Y', + '5Y', + 'ALL', +]; const PREP_FX_CACHE_INTERVALS = FIAT_RATE_SERIES_CACHED_INTERVALS; const EMPTY_BALANCE_SNAPSHOTS: BalanceSnapshot[] = []; @@ -214,7 +223,7 @@ const BalanceHistoryChart = ({ wallets, snapshotsByWalletId, quoteCurrency, - initialSelectedTimeframe = 'ALL', + initialSelectedTimeframe = '1D', rates, fiatRateSeriesCache, lineColor, @@ -448,7 +457,13 @@ const BalanceHistoryChart = ({ }, [selectedTimeframe]); const fiatChartTimeframeOptions = useMemo( - () => getFiatChartTimeframeOptions(t), + () => + getFiatChartTimeframeOptions(t).sort((a, b) => { + return ( + BALANCE_CHART_TIMEFRAME_ORDER.indexOf(a.value) - + BALANCE_CHART_TIMEFRAME_ORDER.indexOf(b.value) + ); + }), [t], ); diff --git a/src/navigation/tabs/home/components/PortfolioBalance.tsx b/src/navigation/tabs/home/components/PortfolioBalance.tsx index 1fca514629..29c2080f5d 100644 --- a/src/navigation/tabs/home/components/PortfolioBalance.tsx +++ b/src/navigation/tabs/home/components/PortfolioBalance.tsx @@ -131,7 +131,7 @@ const PortfolioBalance = () => { const collapseButtonPressOpacity = useSharedValue(1); const [collapseButtonLayout, setCollapseButtonLayout] = useState(); - const selectedChartTimeframeRef = React.useRef('ALL'); + const selectedChartTimeframeRef = React.useRef('1D'); const visibleKeys = useMemo( () => getVisibleKeysFromKeys(keys, homeCarouselConfig), From d54ffb5eca2207206c1727acaea7554fddc19334 Mon Sep 17 00:00:00 2001 From: Marty Alcala Date: Sun, 22 Mar 2026 23:28:41 -0400 Subject: [PATCH 015/138] fix: fix misc ts errors --- src/components/chain-search/ChainSearch.tsx | 8 +- src/components/charts/BalanceHistoryChart.tsx | 69 ++-- .../charts/balanceHistoryChartDataPrep.ts | 2 +- src/components/charts/fiatTimeframes.ts | 16 +- .../tabs/home/components/Crypto.tsx | 5 +- .../tabs/home/components/PortfolioBalance.tsx | 5 +- .../tabs/home/screens/Allocation.tsx | 9 +- src/navigation/wallet/WalletGroup.tsx | 4 +- .../wallet/screens/AccountDetails.tsx | 8 +- src/navigation/wallet/screens/Copayers.tsx | 48 ++- .../wallet/screens/ExchangeRate.tsx | 6 +- src/navigation/wallet/screens/KeyOverview.tsx | 319 ++++++++++-------- src/store/wallet/utils/wallet.ts | 109 ++++-- src/store/wallet/wallet.models.ts | 5 +- src/utils/portfolio/assets.ts | 53 +-- src/utils/text.ts | 3 + 16 files changed, 374 insertions(+), 295 deletions(-) diff --git a/src/components/chain-search/ChainSearch.tsx b/src/components/chain-search/ChainSearch.tsx index 341c38d4cf..235df5c300 100644 --- a/src/components/chain-search/ChainSearch.tsx +++ b/src/components/chain-search/ChainSearch.tsx @@ -31,6 +31,8 @@ import {AccountRowProps} from '../list/AccountListRow'; import {WalletRowProps} from '../list/WalletRow'; import {TouchableOpacity} from '@components/base/TouchableOpacity'; +type SearchableWallet = Wallet | WalletRowProps; + export const SearchIconContainer = styled.View` margin: 14px; `; @@ -87,7 +89,7 @@ export interface SearchableItem { accountName?: string; chainAssetsList?: WalletRowProps[]; chains?: string[]; // (Global Select view) - wallets?: Wallet[]; // (Key Overview view) + wallets?: SearchableWallet[]; // (Key Overview view) chain?: string; // (Key Overview view) availableWallets?: Wallet[]; availableWalletsByKey?: { @@ -353,12 +355,12 @@ const SearchComponent = ({ } else { const accounts = data.accounts as (AccountRowProps & { assetsByChain?: AssetsByChainData[]; - wallets?: Wallet[]; + wallets?: SearchableWallet[]; })[]; const filteredAccounts = accounts ?.map(account => { - let filteredWallets = account.wallets as Wallet[]; + let filteredWallets = account.wallets || []; if (selectedChainFilterOption) { filteredWallets = filteredWallets?.filter( ({chain}) => chain === selectedChainFilterOption, diff --git a/src/components/charts/BalanceHistoryChart.tsx b/src/components/charts/BalanceHistoryChart.tsx index 68a21a417b..592fa8422d 100644 --- a/src/components/charts/BalanceHistoryChart.tsx +++ b/src/components/charts/BalanceHistoryChart.tsx @@ -32,6 +32,8 @@ import { type PnlAnalysisPoint, } from '../../utils/portfolio/core/pnl/analysis'; import { + DEFAULT_BALANCE_CHART_TIMEFRAME, + FIAT_CHART_DISPLAY_ORDER, getFiatChartTimeframeOptions, getRangeLabelForFiatTimeframe, getSeriesIntervalForFiatTimeframe, @@ -109,22 +111,7 @@ import {useStableBalanceHistoryChartAxisLabels} from './useStableBalanceHistoryC const CHART_LOADER_DELAY_MS = 150; const CHART_COMPUTE_YIELD_EVERY_POINTS = 4; const PRECOMPUTE_TIMEFRAME_ORDER: FiatRateInterval[] = [ - '1D', - '1W', - '1M', - 'ALL', - '3M', - '1Y', - '5Y', -]; -const BALANCE_CHART_TIMEFRAME_ORDER: FiatRateInterval[] = [ - '1D', - '1W', - '1M', - '3M', - '1Y', - '5Y', - 'ALL', + ...FIAT_CHART_DISPLAY_ORDER, ]; const PREP_FX_CACHE_INTERVALS = FIAT_RATE_SERIES_CACHED_INTERVALS; const EMPTY_BALANCE_SNAPSHOTS: BalanceSnapshot[] = []; @@ -223,7 +210,7 @@ const BalanceHistoryChart = ({ wallets, snapshotsByWalletId, quoteCurrency, - initialSelectedTimeframe = '1D', + initialSelectedTimeframe = DEFAULT_BALANCE_CHART_TIMEFRAME, rates, fiatRateSeriesCache, lineColor, @@ -273,8 +260,10 @@ const BalanceHistoryChart = ({ } | undefined >(undefined); - const [hasCompletedInitialAllLoad, setHasCompletedInitialAllLoad] = - useState(false); + const [ + hasCompletedInitialInteractiveLoad, + setHasCompletedInitialInteractiveLoad, + ] = useState(false); const [isChartLoaderVisible, setIsChartLoaderVisible] = useState(false); const computeGenerationRef = useRef(0); @@ -457,13 +446,7 @@ const BalanceHistoryChart = ({ }, [selectedTimeframe]); const fiatChartTimeframeOptions = useMemo( - () => - getFiatChartTimeframeOptions(t).sort((a, b) => { - return ( - BALANCE_CHART_TIMEFRAME_ORDER.indexOf(a.value) - - BALANCE_CHART_TIMEFRAME_ORDER.indexOf(b.value) - ); - }), + () => getFiatChartTimeframeOptions(t), [t], ); @@ -663,7 +646,7 @@ const BalanceHistoryChart = ({ hasAnyChartableSnapshots && !!fiatRateSeriesCache && (selectedTimeframeNeedsHistoricalRecompute || - (hasCompletedInitialAllLoad && + (hasCompletedInitialInteractiveLoad && hasAnyBackgroundHistoricalRecomputeNeeded)); useEffect(() => { @@ -1072,7 +1055,7 @@ const BalanceHistoryChart = ({ setAnalysisInputs(EMPTY_ANALYSIS_INPUTS(quoteCurrency)); setAnalysisInputsReadyRevision(undefined); setAnalysisInputsErrorRevision(undefined); - setHasCompletedInitialAllLoad(false); + setHasCompletedInitialInteractiveLoad(false); dispatchTimeframeState({ type: 'resetAll', generation, @@ -1249,7 +1232,7 @@ const BalanceHistoryChart = ({ if (!inputsReady || !hasAnyChartableSnapshots) { return; } - if (!hasCompletedInitialAllLoad) { + if (!hasCompletedInitialInteractiveLoad) { return; } @@ -1272,7 +1255,7 @@ const BalanceHistoryChart = ({ }, [ getComputeDispositionForTimeframe, hasAnyChartableSnapshots, - hasCompletedInitialAllLoad, + hasCompletedInitialInteractiveLoad, inputsReady, queueTimeframeCompute, selectedTimeframe, @@ -1313,17 +1296,17 @@ const BalanceHistoryChart = ({ hasAnyChartableSnapshots && isSelectedTimeframePending; useEffect(() => { + if (hasRenderableSelectedSeries && !hasCompletedInitialInteractiveLoad) { + setHasCompletedInitialInteractiveLoad(true); + } + if (!isChartLoadingRaw) { setIsChartLoaderVisible(false); - if (!hasCompletedInitialAllLoad && selectedTimeframe === 'ALL') { - setHasCompletedInitialAllLoad(true); - } return; } - const isInitialAllLoad = - selectedTimeframe === 'ALL' && !hasCompletedInitialAllLoad; - if (isInitialAllLoad) { + const isInitialInteractiveLoad = !hasCompletedInitialInteractiveLoad; + if (isInitialInteractiveLoad) { setIsChartLoaderVisible(true); return; } @@ -1334,12 +1317,14 @@ const BalanceHistoryChart = ({ }, CHART_LOADER_DELAY_MS); return () => clearTimeout(timer); - }, [hasCompletedInitialAllLoad, isChartLoadingRaw, selectedTimeframe]); + }, [ + hasCompletedInitialInteractiveLoad, + hasRenderableSelectedSeries, + isChartLoadingRaw, + ]); - const hideGuideLineForInitialAllLoader = - selectedTimeframe === 'ALL' && - !hasCompletedInitialAllLoad && - isChartLoaderVisible; + const hideGuideLineForInitialInteractiveLoader = + !hasCompletedInitialInteractiveLoad && isChartLoaderVisible; const hasAnyRenderableSeries = !!activeSeries || Object.values(timeframeStateByTimeframe).some( @@ -1447,7 +1432,7 @@ const BalanceHistoryChart = ({ gradientBackgroundColor, theme.dark ? 'transparent' : White, ]} - showFirstPointGuideLine={!hideGuideLineForInitialAllLoader} + showFirstPointGuideLine={!hideGuideLineForInitialInteractiveLoader} isLoading={isChartLoaderVisible} hideLineWhileLoading={!hasAnyRenderableSeries} enablePanGesture={!isChartLoadingRaw && !disablePanGesture} diff --git a/src/components/charts/balanceHistoryChartDataPrep.ts b/src/components/charts/balanceHistoryChartDataPrep.ts index 34462031dc..10f3c78fc3 100644 --- a/src/components/charts/balanceHistoryChartDataPrep.ts +++ b/src/components/charts/balanceHistoryChartDataPrep.ts @@ -79,7 +79,7 @@ export const buildBalanceHistoryChartRelevantRateCacheAssets = ( export const buildBalanceHistoryChartPrepFiatRateSeriesCacheKeys = (args: { quoteCurrency: string; scopedSnapshotsByWalletId: BalanceSnapshotsByWalletId; - prepIntervals: FiatRateInterval[]; + prepIntervals: ReadonlyArray; }): string[] => { const targetQuoteCurrency = (args.quoteCurrency || '').toUpperCase(); if (!targetQuoteCurrency) { diff --git a/src/components/charts/fiatTimeframes.ts b/src/components/charts/fiatTimeframes.ts index 91e383eed2..4885c81552 100644 --- a/src/components/charts/fiatTimeframes.ts +++ b/src/components/charts/fiatTimeframes.ts @@ -1,17 +1,25 @@ import type {FiatRateInterval} from '../../store/rate/rate.models'; import { - FIAT_TIMEFRAME_VALUES, getFiatTimeframeMetadata, getFiatTimeframeSeriesInterval, } from '../../utils/fiatTimeframes'; -export const FIAT_CHART_TIMEFRAME_VALUES: FiatRateInterval[] = - FIAT_TIMEFRAME_VALUES.slice(); +export const FIAT_CHART_DISPLAY_ORDER = [ + '1D', + '1W', + '1M', + '3M', + '1Y', + '5Y', + 'ALL', +] as const satisfies ReadonlyArray; + +export const DEFAULT_BALANCE_CHART_TIMEFRAME = FIAT_CHART_DISPLAY_ORDER[0]; export const getFiatChartTimeframeOptions = ( t: (key: string) => string, ): Array<{label: string; value: FiatRateInterval}> => { - return FIAT_CHART_TIMEFRAME_VALUES.map(value => ({ + return FIAT_CHART_DISPLAY_ORDER.map(value => ({ value, label: t(getFiatTimeframeMetadata(value).displayLabel), })); diff --git a/src/navigation/tabs/home/components/Crypto.tsx b/src/navigation/tabs/home/components/Crypto.tsx index 9477721655..133c52a3de 100644 --- a/src/navigation/tabs/home/components/Crypto.tsx +++ b/src/navigation/tabs/home/components/Crypto.tsx @@ -61,6 +61,7 @@ import { getKeyLastDayPercentageDifference, getPercentageDifferenceFromPercentRatio, getQuoteCurrency, + getVisibleWalletsForKey, hasSnapshotsBeforeMsForWallets, hasSnapshotsForWallets, isPopulateLoadingForWallets, @@ -266,9 +267,7 @@ export const createHomeCardList = ({ backupComplete, } = key; - wallets = wallets.filter( - wallet => !wallet.hideWallet && !wallet.hideWalletByAccount, - ); + wallets = getVisibleWalletsForKey(key); const isKeyPopulateLoading = isPopulateLoadingForWallets({ populateStatus, diff --git a/src/navigation/tabs/home/components/PortfolioBalance.tsx b/src/navigation/tabs/home/components/PortfolioBalance.tsx index 29c2080f5d..30873f4975 100644 --- a/src/navigation/tabs/home/components/PortfolioBalance.tsx +++ b/src/navigation/tabs/home/components/PortfolioBalance.tsx @@ -18,6 +18,7 @@ import { } from '../../../../store/app/app.actions'; import BalanceHistoryChart from '../../../../components/charts/BalanceHistoryChart'; import ChartChangeRow from '../../../../components/charts/ChartChangeRow'; +import {DEFAULT_BALANCE_CHART_TIMEFRAME} from '../../../../components/charts/fiatTimeframes'; import {COINBASE_ENV} from '../../../../api/coinbase/coinbase.constants'; import {useTranslation} from 'react-i18next'; import {TouchableOpacity} from '@components/base/TouchableOpacity'; @@ -131,7 +132,9 @@ const PortfolioBalance = () => { const collapseButtonPressOpacity = useSharedValue(1); const [collapseButtonLayout, setCollapseButtonLayout] = useState(); - const selectedChartTimeframeRef = React.useRef('1D'); + const selectedChartTimeframeRef = React.useRef( + DEFAULT_BALANCE_CHART_TIMEFRAME, + ); const visibleKeys = useMemo( () => getVisibleKeysFromKeys(keys, homeCarouselConfig), diff --git a/src/navigation/tabs/home/screens/Allocation.tsx b/src/navigation/tabs/home/screens/Allocation.tsx index 390b6c1227..b3fddb8ef3 100644 --- a/src/navigation/tabs/home/screens/Allocation.tsx +++ b/src/navigation/tabs/home/screens/Allocation.tsx @@ -20,7 +20,10 @@ import { type AllocationWallet, toAllocationWallet, } from '../../../../utils/portfolio/allocation'; -import {getVisibleWalletsFromKeys} from '../../../../utils/portfolio/assets'; +import { + getVisibleWalletsForKey, + getVisibleWalletsFromKeys, +} from '../../../../utils/portfolio/assets'; import {LightBlack, Slate30, SlateDark} from '../../../../styles/colors'; import {maskIfHidden} from '../../../../utils/hideBalances'; import {useAssetIconResolver} from '../hooks/useAssetIconResolver'; @@ -277,9 +280,7 @@ const Allocation: React.FC = ({navigation, route}) => { return (account?.wallets || []) as AllocationWallet[]; } - const wallets = key.wallets.filter( - w => !w.hideWallet && !w.hideWalletByAccount, - ); + const wallets = getVisibleWalletsForKey(key); return wallets.map((w: Wallet) => { return toAllocationWallet(w); diff --git a/src/navigation/wallet/WalletGroup.tsx b/src/navigation/wallet/WalletGroup.tsx index 78fd326985..350ce39d9a 100644 --- a/src/navigation/wallet/WalletGroup.tsx +++ b/src/navigation/wallet/WalletGroup.tsx @@ -26,7 +26,7 @@ import CreateEncryptionPassword from './screens/CreateEncryptionPassword'; import { Key, Wallet as WalletModel, - _Credentials, + type WalletStatusPayload, } from '../../store/wallet/wallet.models'; import ExtendedPrivateKey from './screens/ExtendedPrivateKey'; import DeleteKey from './screens/DeleteKey'; @@ -150,7 +150,7 @@ export type WalletGroupParamList = { PayProConfirmTwoFactor: PayProConfirmTwoFactorParamList; CreateMultisig: CreateMultisigParamsList; JoinMultisig: JoinMultisigParamList | undefined; - Copayers: {wallet: WalletModel; status: _Credentials}; + Copayers: {wallet: WalletModel; status: WalletStatusPayload}; InviteCosigners: {keyId: string}; ShareJoinCode: {keyId: string; partyId: number; joinCode: string}; JoinTSSWallet: {copayerName?: string; keyId?: string}; diff --git a/src/navigation/wallet/screens/AccountDetails.tsx b/src/navigation/wallet/screens/AccountDetails.tsx index 8a008c0e80..2c486fb529 100644 --- a/src/navigation/wallet/screens/AccountDetails.tsx +++ b/src/navigation/wallet/screens/AccountDetails.tsx @@ -17,7 +17,6 @@ import {useAppDispatch, useAppSelector} from '../../../utils/hooks'; import { Wallet, TransactionProposal, - Status, KeyMethods, } from '../../../store/wallet/wallet.models'; import styled from 'styled-components/native'; @@ -1234,7 +1233,7 @@ const AccountDetails: React.FC = ({route}) => { if (!fullWalletObj.isComplete() && fullWalletObj?.pendingTssSession) { fullWalletObj.getStatus( {network: fullWalletObj.network}, - (err: any, status: Status) => { + (err, status) => { if (err) { const errStr = err instanceof Error ? err.message : JSON.stringify(err); @@ -1249,9 +1248,12 @@ const AccountDetails: React.FC = ({route}) => { }); return; } + if (!status?.wallet) { + return; + } navigation.navigate('Copayers', { wallet: fullWalletObj, - status: status?.wallet, + status: status.wallet, }); } }, diff --git a/src/navigation/wallet/screens/Copayers.tsx b/src/navigation/wallet/screens/Copayers.tsx index 636e6b5d83..7abac64164 100644 --- a/src/navigation/wallet/screens/Copayers.tsx +++ b/src/navigation/wallet/screens/Copayers.tsx @@ -14,7 +14,6 @@ import { HeaderTitle, } from '../../../components/styled/Text'; import { - TitleContainer, RowContainer, ActiveOpacity, CtaContainer, @@ -27,7 +26,6 @@ import {useNavigation} from '@react-navigation/native'; import Button from '../../../components/button/Button'; import {useTranslation} from 'react-i18next'; import {useLogger} from '../../../utils/hooks'; -import {Status} from '../../../store/wallet/wallet.models'; const CircleCheckIcon = require('../../../../assets/img/circle-check.png'); interface CopayersProps { @@ -90,30 +88,52 @@ const Copayers: React.FC = props => { {`${walletName} [${wallet?.m}-${wallet?.n}]`} ), }); - }, [navigation]); + }, [ + navigation, + wallet?.credentials?.walletName, + wallet?.currencyName, + wallet?.m, + wallet?.n, + wallet?.walletName, + ]); const onRefresh = async () => { setRefreshing(true); - await updateWalletStatus(); - setRefreshing(false); + try { + await updateWalletStatus(); + } finally { + setRefreshing(false); + } }; const updateWalletStatus = () => { + if (!wallet) { + return Promise.resolve(); + } + return new Promise(resolve => { - wallet?.getStatus({network: wallet?.network}, (err: any, st: Status) => { + wallet.getStatus({}, (err, st) => { if (err) { const errStr = err instanceof Error ? err.message : JSON.stringify(err); logger.error(`error [updateWalletStatus] [getStatus]: ${errStr}`); - } else { - setWalletStatus(st?.wallet); - if (st?.wallet && st?.wallet?.status === 'complete') { - wallet.openWallet({}, () => { - navigationRef.goBack(); - }); - } + resolve(); + return; + } + + if (!st?.wallet) { + resolve(); + return; + } + + setWalletStatus(st.wallet); + if (st.wallet.status === 'complete') { + wallet.openWallet({}, () => { + navigationRef.goBack(); + }); } - return resolve(); + + resolve(); }); }); }; diff --git a/src/navigation/wallet/screens/ExchangeRate.tsx b/src/navigation/wallet/screens/ExchangeRate.tsx index d397fb8cf1..72c7e11205 100644 --- a/src/navigation/wallet/screens/ExchangeRate.tsx +++ b/src/navigation/wallet/screens/ExchangeRate.tsx @@ -118,6 +118,7 @@ import InteractiveLineChart, { import TimeframeSelector from '../../../components/charts/TimeframeSelector'; import ChartChangeRow from '../../../components/charts/ChartChangeRow'; import { + DEFAULT_BALANCE_CHART_TIMEFRAME, formatRangeOrSelectedPointLabel, getFiatChartTimeframeOptions, getRangeLabelForFiatTimeframe, @@ -474,8 +475,9 @@ const ExchangeRate = () => { ({APP}: RootState) => APP.hideAllBalances, ); const {params} = useRoute>(); - const [selectedTimeframe, setSelectedTimeframe] = - useState('ALL'); + const [selectedTimeframe, setSelectedTimeframe] = useState( + DEFAULT_BALANCE_CHART_TIMEFRAME, + ); const [isAboutExpanded, setIsAboutExpanded] = useState(false); const [isChartLoading, setIsChartLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); diff --git a/src/navigation/wallet/screens/KeyOverview.tsx b/src/navigation/wallet/screens/KeyOverview.tsx index 906c46468f..3b11eb4d25 100644 --- a/src/navigation/wallet/screens/KeyOverview.tsx +++ b/src/navigation/wallet/screens/KeyOverview.tsx @@ -50,12 +50,7 @@ import { updatePortfolioBalance, syncWallets, } from '../../../store/wallet/wallet.actions'; -import { - Key, - KeyMethods, - Status, - Wallet, -} from '../../../store/wallet/wallet.models'; +import {Key, KeyMethods, Wallet} from '../../../store/wallet/wallet.models'; import { CharcoalBlack, GhostWhite, @@ -97,7 +92,6 @@ import { buildWalletObj, checkPrivateKeyEncrypted, } from '../../../store/wallet/utils/wallet'; -import {each} from 'lodash'; import {COINBASE_ENV} from '../../../api/coinbase/coinbase.constants'; import CoinbaseDropdownOption from '../components/CoinbaseDropdownOption'; import {Analytics} from '../../../store/analytics/analytics.effects'; @@ -360,18 +354,13 @@ const KeyOverview = () => { const [showKeyDropdown, setShowKeyDropdown] = useState(false); const key = keys[id]; + const viewedKeyId = key?.id; useEffect(() => { setSelectedBalance(undefined); }, [id]); const hasMultipleKeys = Object.values(keys).filter(k => k.backupComplete).length > 1; - let pendingTxps: any = []; - each(key?.wallets, x => { - if (x.pendingTxps) { - pendingTxps = pendingTxps.concat(x.pendingTxps); - } - }); const [isLoadingInitial, setIsLoadingInitial] = useState(true); const [searchVal, setSearchVal] = useState(''); const [isViewUpdating, setIsViewUpdating] = useState(false); @@ -379,15 +368,48 @@ const KeyOverview = () => { const selectedChainFilterOption = useAppSelector( ({APP}) => APP.selectedChainFilterOption, ); + + const memoizedAccountList = useMemo(() => { + return buildAccountList(key, defaultAltCurrency.isoCode, rates, dispatch, { + filterByHideWallet: true, + }); + }, [dispatch, key, defaultAltCurrency.isoCode, rates]); + + const pendingTxpCount = useMemo(() => { + return ( + key?.wallets.reduce((count, wallet) => { + return count + (wallet.pendingTxps?.length || 0); + }, 0) || 0 + ); + }, [key?.wallets]); + + const missingChainsAccountsCount = useMemo(() => { + const supportedEvmChainCount = Object.keys(BitpaySupportedEvmCoins).length; + + return memoizedAccountList.reduce((count, {chains}) => { + return ( + count + + (IsEVMChain(chains[0]) && chains.length !== supportedEvmChainCount + ? 1 + : 0) + ); + }, 0); + }, [memoizedAccountList]); + + const hasMissingEvmNetworks = missingChainsAccountsCount > 0; + + const onPressTxpBadge = useCallback(() => { + if (!key?.id) { + return; + } + + navigation.navigate('TransactionProposalNotifications', {keyId: key.id}); + }, [key?.id, navigation]); + useLayoutEffect(() => { if (!key) { return; } - const missingChainsAccounts = memorizedAccountList.filter( - ({chains}) => - IsVMChain(chains[0]) && - chains.length !== Object.keys(BitpaySupportedEvmCoins).length, - ); navigation.setOptions({ headerTitle: () => { @@ -424,16 +446,15 @@ const KeyOverview = () => { return ( <> - {pendingTxps.length ? ( + {pendingTxpCount ? ( - {pendingTxps.length} + {pendingTxpCount} ) : null} - {checkPrivateKeyEncrypted(key) && - missingChainsAccounts.length === 0 ? ( + {checkPrivateKeyEncrypted(key) && !hasMissingEvmNetworks ? ( { await sleep(500); @@ -458,49 +479,44 @@ const KeyOverview = () => { ); }, }); - }, [navigation, key, hasMultipleKeys, theme.dark]); + }, [ + navigation, + key, + hasMultipleKeys, + linkedCoinbase, + hasMissingEvmNetworks, + onPressTxpBadge, + pendingTxpCount, + theme.dark, + ]); - useEffect(() => { - if (context === 'createNewMultisigKey') { - key?.wallets[0].getStatus( - {network: key?.wallets[0].network}, - (err: any, status: Status) => { - if (err) { - const errStr = - err instanceof Error ? err.message : JSON.stringify(err); - logger.error( - `error [KeyOverview - createNewMultisigKey] [getStatus]: ${errStr}`, - ); - } else { - navigation.navigate('Copayers', { - wallet: key?.wallets[0], - status: status?.wallet, - }); - } - }, - ); - } - }, [navigation, key?.wallets, context]); + const firstWallet = key?.wallets?.[0]; useEffect(() => { - if (!key) { + if (context !== 'createNewMultisigKey' || !firstWallet) { return; } - dispatch(Analytics.track('View Key')); - updateStatusForKey(false); - }, [dispatch, key?.id]); - - const { - totalBalance = 0, - totalBalanceLastDay, - } = useAppSelector(({WALLET}) => WALLET.keys[id]) || {}; - - const memorizedAccountList = useMemo(() => { - return buildAccountList(key, defaultAltCurrency.isoCode, rates, dispatch, { - filterByHideWallet: true, + firstWallet.getStatus({}, (err, status) => { + if (err) { + const errStr = err instanceof Error ? err.message : JSON.stringify(err); + logger.error( + `error [KeyOverview - createNewMultisigKey] [getStatus]: ${errStr}`, + ); + } else { + if (!status?.wallet) { + return; + } + navigation.navigate('Copayers', { + wallet: firstWallet, + status: status.wallet, + }); + } }); - }, [dispatch, key, defaultAltCurrency.isoCode, rates, hideAllBalances]); + }, [context, firstWallet, logger, navigation]); + + const {totalBalance = 0, totalBalanceLastDay} = + useAppSelector(({WALLET}) => WALLET.keys[id]) || {}; const visibleKeyWallets = useMemo(() => { return getVisibleWalletsForKey(key); @@ -533,9 +549,17 @@ const KeyOverview = () => { }, [defaultAltCurrency?.isoCode, portfolio.quoteCurrency]); const visibleKeyWalletIdsSig = useMemo(() => { - return visibleKeyWallets - .map(w => w?.id) - .filter((id): id is string => typeof id === 'string' && !!id) + return Array.from( + new Set( + visibleKeyWallets + .map(w => w?.id) + .filter( + (walletId): walletId is string => + typeof walletId === 'string' && !!walletId, + ), + ), + ) + .sort((a, b) => a.localeCompare(b)) .join(','); }, [visibleKeyWallets]); @@ -581,7 +605,7 @@ const KeyOverview = () => { return; } - void maybeRefreshKeyBalanceChart(); + maybeRefreshKeyBalanceChart(); }, [ isFocused, maybeRefreshKeyBalanceChart, @@ -598,7 +622,7 @@ const KeyOverview = () => { return; } - void maybeRefreshKeyBalanceChart(); + maybeRefreshKeyBalanceChart(); }, [ isFocused, maybeRefreshKeyBalanceChart, @@ -872,11 +896,6 @@ const KeyOverview = () => { } }; - const missingChainsAccounts = memorizedAccountList.filter( - ({chains}) => - IsEVMChain(chains[0]) && - chains.length !== Object.keys(BitpaySupportedEvmCoins).length, - ); const keyOptions: Array