From 0b96f2ec52ca436f033429ca5aab4214834fec35 Mon Sep 17 00:00:00 2001 From: Yaroslav Grachev Date: Thu, 21 Sep 2023 14:06:48 +0300 Subject: [PATCH] Feat: Price provider model (#1084) --- src/renderer/app/App.tsx | 6 + src/renderer/assets/currency/currencies.json | 424 ++++++++++++++++++ src/renderer/entities/price/index.ts | 2 + src/renderer/entities/price/lib/constants.ts | 6 + src/renderer/entities/price/lib/types.ts | 3 + .../model/__tests__/currency-model.test.ts | 61 +++ .../__tests__/price-provider-model.test.ts | 77 ++++ .../entities/price/model/currency-model.ts | 77 ++++ .../price/model/price-provider-model.ts | 99 ++++ .../shared/api/local-storage/index.ts | 1 + .../service/localStorageService.ts | 22 + .../__tests__/coingeckoService.test.ts} | 18 +- .../__tests__/fiatService.test.ts | 80 ++++ .../__tests__}/utils.test.ts | 4 +- .../api/price-provider/common/constants.ts | 5 + .../{price => price-provider}/common/types.ts | 10 + .../{price => price-provider}/common/utils.ts | 17 +- .../shared/api/price-provider/index.ts | 3 + .../service/coingeckoService.ts | 47 ++ .../api/price-provider/service/fiatService.ts | 52 +++ .../api/price/coingecko/CoingeckoAdapter.ts | 51 --- .../shared/api/price/coingecko/consts.ts | 1 - src/renderer/shared/api/price/index.ts | 3 - src/renderer/shared/core/index.ts | 2 +- .../shared/core/model/kernel-model.ts | 6 +- 25 files changed, 996 insertions(+), 81 deletions(-) create mode 100644 src/renderer/assets/currency/currencies.json create mode 100644 src/renderer/entities/price/index.ts create mode 100644 src/renderer/entities/price/lib/constants.ts create mode 100644 src/renderer/entities/price/lib/types.ts create mode 100644 src/renderer/entities/price/model/__tests__/currency-model.test.ts create mode 100644 src/renderer/entities/price/model/__tests__/price-provider-model.test.ts create mode 100644 src/renderer/entities/price/model/currency-model.ts create mode 100644 src/renderer/entities/price/model/price-provider-model.ts create mode 100644 src/renderer/shared/api/local-storage/index.ts create mode 100644 src/renderer/shared/api/local-storage/service/localStorageService.ts rename src/renderer/shared/api/{price/coingecko/CoingeckoAdapter.test.ts => price-provider/__tests__/coingeckoService.test.ts} (66%) create mode 100644 src/renderer/shared/api/price-provider/__tests__/fiatService.test.ts rename src/renderer/shared/api/{price/common => price-provider/__tests__}/utils.test.ts (93%) create mode 100644 src/renderer/shared/api/price-provider/common/constants.ts rename src/renderer/shared/api/{price => price-provider}/common/types.ts (78%) rename src/renderer/shared/api/{price => price-provider}/common/utils.ts (65%) create mode 100644 src/renderer/shared/api/price-provider/index.ts create mode 100644 src/renderer/shared/api/price-provider/service/coingeckoService.ts create mode 100644 src/renderer/shared/api/price-provider/service/fiatService.ts delete mode 100644 src/renderer/shared/api/price/coingecko/CoingeckoAdapter.ts delete mode 100644 src/renderer/shared/api/price/coingecko/consts.ts delete mode 100644 src/renderer/shared/api/price/index.ts diff --git a/src/renderer/app/App.tsx b/src/renderer/app/App.tsx index 4cd180c77c..229a3b5739 100644 --- a/src/renderer/app/App.tsx +++ b/src/renderer/app/App.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { useNavigate, useRoutes } from 'react-router-dom'; +import { useUnit } from 'effector-react'; import { FallbackScreen } from '@renderer/components/common'; import { useAccount } from '@renderer/entities/account'; +import { priceProviderModel, currencyModel } from '@renderer/entities/price'; import { ConfirmDialogProvider, I18Provider, @@ -22,6 +24,10 @@ const App = () => { const appRoutes = useRoutes(routesConfig); const { getAccounts } = useAccount(); + const [assetsPrices, activeCurrency] = useUnit([priceProviderModel.$assetsPrices, currencyModel.$activeCurrency]); + console.log('🔴 assetsPrices === > ', assetsPrices); + console.log('🔴 currency === > ', activeCurrency); + const [showSplashScreen, setShowSplashScreen] = useState(true); const [isAccountsLoading, setIsAccountsLoading] = useState(true); diff --git a/src/renderer/assets/currency/currencies.json b/src/renderer/assets/currency/currencies.json new file mode 100644 index 0000000000..73a0966c3c --- /dev/null +++ b/src/renderer/assets/currency/currencies.json @@ -0,0 +1,424 @@ +[ + { + "code": "USD", + "name": "United States Dollar", + "symbol": "$", + "category": "fiat", + "popular": true, + "id": 0, + "coingeckoId": "usd" + }, + { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "category": "fiat", + "popular": true, + "id": 1, + "coingeckoId": "eur" + }, + { + "code": "JPY", + "name": "Japanese Yen", + "symbol": "¥", + "category": "fiat", + "popular": true, + "id": 2, + "coingeckoId": "jpy" + }, + { + "code": "CNY", + "name": "Chinese Yuan", + "symbol": "¥", + "category": "fiat", + "popular": true, + "id": 3, + "coingeckoId": "cny" + }, + { + "code": "TWD", + "name": "New Taiwan dollar", + "symbol": "$", + "category": "fiat", + "popular": true, + "id": 4, + "coingeckoId": "twd" + }, + { + "code": "RUB", + "name": "Russian Ruble", + "symbol": "₽", + "category": "fiat", + "popular": true, + "id": 5, + "coingeckoId": "rub" + }, + { + "code": "AED", + "name": "United Arab Emirates dirham", + "category": "fiat", + "popular": true, + "id": 6, + "coingeckoId": "aed" + }, + { + "code": "IDR", + "name": "Indonesian Rupiah", + "category": "fiat", + "popular": true, + "id": 7, + "coingeckoId": "idr" + }, + { + "code": "KRW", + "name": "South Korean won", + "symbol": "₩", + "category": "fiat", + "popular": true, + "id": 8, + "coingeckoId": "krw" + }, + { + "code": "ARS", + "name": "Argentine Peso", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 9, + "coingeckoId": "ars" + }, + { + "code": "AUD", + "name": "Australian Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 10, + "coingeckoId": "aud" + }, + { + "code": "BDT", + "name": "Bangladeshi Taka", + "category": "fiat", + "popular": false, + "id": 11, + "coingeckoId": "bdt" + }, + { + "code": "BHD", + "name": "Bahraini Dinar", + "category": "fiat", + "popular": false, + "id": 12, + "coingeckoId": "bhd" + }, + { + "code": "BMD", + "name": "Bermudan Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 13, + "coingeckoId": "bmd" + }, + { + "code": "BRL", + "name": "Brazilian Real", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 14, + "coingeckoId": "brl" + }, + { + "code": "CAD", + "name": "Canadian Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 15, + "coingeckoId": "cad" + }, + { + "code": "CHF", + "name": "Swiss Franc", + "category": "fiat", + "popular": false, + "id": 16, + "coingeckoId": "chf" + }, + { + "code": "CLP", + "name": "Chilean Peso", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 17, + "coingeckoId": "clp" + }, + { + "code": "CZK", + "name": "Czech Koruna", + "symbol": "Kč", + "category": "fiat", + "popular": false, + "id": 18, + "coingeckoId": "czk" + }, + { + "code": "DKK", + "name": "Danish Krone", + "category": "fiat", + "popular": false, + "id": 19, + "coingeckoId": "dkk" + }, + { + "code": "GBP", + "name": "British Pound Sterling", + "symbol": "£", + "category": "fiat", + "popular": false, + "id": 20, + "coingeckoId": "gbp" + }, + { + "code": "HKD", + "name": "Hong Kong Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 21, + "coingeckoId": "hkd" + }, + { + "code": "HUF", + "name": "Hungarian Forint", + "category": "fiat", + "popular": false, + "id": 22, + "coingeckoId": "huf" + }, + { + "code": "ILS", + "name": "Israeli New Shekel", + "symbol": "₪", + "category": "fiat", + "popular": false, + "id": 23, + "coingeckoId": "ils" + }, + { + "code": "INR", + "name": "Indian Rupee", + "symbol": "₹", + "category": "fiat", + "popular": false, + "id": 24, + "coingeckoId": "inr" + }, + { + "code": "KDW", + "name": "Kuwaiti Dinar", + "category": "fiat", + "popular": false, + "id": 25, + "coingeckoId": "kdw" + }, + { + "code": "LKR", + "name": "Sri Lankan Rupee", + "category": "fiat", + "popular": false, + "id": 26, + "coingeckoId": "lkr" + }, + { + "code": "MMK", + "name": "Myanmar Kyat", + "category": "fiat", + "popular": false, + "id": 27, + "coingeckoId": "mmk" + }, + { + "code": "MXN", + "name": "Mexican Peso", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 28, + "coingeckoId": "mxn" + }, + { + "code": "MYR", + "name": "Malaysian Ringgit", + "category": "fiat", + "popular": false, + "id": 29, + "coingeckoId": "myr" + }, + { + "code": "NGN", + "name": "Nigerian Naira", + "symbol": "₦", + "category": "fiat", + "popular": false, + "id": 30, + "coingeckoId": "ngn" + }, + { + "code": "NOK", + "name": "Norwegian Krone", + "category": "fiat", + "popular": false, + "id": 31, + "coingeckoId": "nok" + }, + { + "code": "NZD", + "name": "New Zealand Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 32, + "coingeckoId": "nzd" + }, + { + "code": "PHP", + "name": "Philippine peso", + "symbol": "₱", + "category": "fiat", + "popular": false, + "id": 33, + "coingeckoId": "php" + }, + { + "code": "PKR", + "name": "Pakistani Rupee", + "category": "fiat", + "popular": false, + "id": 34, + "coingeckoId": "pkr" + }, + { + "code": "PLN", + "name": "Poland złoty", + "symbol": "zł", + "category": "fiat", + "popular": false, + "id": 35, + "coingeckoId": "pln" + }, + { + "code": "SAR", + "name": "Saudi Riyal", + "category": "fiat", + "popular": false, + "id": 36, + "coingeckoId": "sar" + }, + { + "code": "SEK", + "name": "Swedish Krona", + "category": "fiat", + "popular": false, + "id": 37, + "coingeckoId": "sek" + }, + { + "code": "SGD", + "name": "Singapore Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 38, + "coingeckoId": "sgd" + }, + { + "code": "THB", + "name": "Thai Baht", + "symbol": "฿", + "category": "fiat", + "popular": false, + "id": 39, + "coingeckoId": "thb" + }, + { + "code": "TRY", + "name": "Turkish lira", + "symbol": "₺", + "category": "fiat", + "popular": false, + "id": 40, + "coingeckoId": "try" + }, + { + "code": "UAH", + "name": "Ukrainian hryvnia", + "symbol": "₴", + "category": "fiat", + "popular": false, + "id": 41, + "coingeckoId": "uah" + }, + { + "code": "VEF", + "name": "Venezuelan bolívar", + "category": "fiat", + "popular": false, + "id": 42, + "coingeckoId": "vef" + }, + { + "code": "VND", + "name": "Vietnamese dong", + "symbol": "₫", + "category": "fiat", + "popular": false, + "id": 43, + "coingeckoId": "vnd" + }, + { + "code": "ZAR", + "name": "South African rand", + "category": "fiat", + "popular": false, + "id": 44, + "coingeckoId": "zar" + }, + { + "code": "XDR", + "name": "IMF Special Drawing Rights", + "category": "fiat", + "popular": false, + "id": 45, + "coingeckoId": "xdr" + }, + { + "code": "DOT", + "name": "Polkadot", + "category": "crypto", + "popular": true, + "id": 46, + "coingeckoId": "dot" + }, + { + "code": "BTC", + "name": "Bitcoin", + "symbol": "₿", + "category": "crypto", + "popular": true, + "id": 47, + "coingeckoId": "btc" + }, + { + "code": "ETH", + "name": "Ether", + "symbol": "Ξ", + "category": "crypto", + "popular": true, + "id": 48, + "coingeckoId": "eth" + } +] diff --git a/src/renderer/entities/price/index.ts b/src/renderer/entities/price/index.ts new file mode 100644 index 0000000000..26960d7f11 --- /dev/null +++ b/src/renderer/entities/price/index.ts @@ -0,0 +1,2 @@ +export { priceProviderModel } from './model/price-provider-model'; +export { currencyModel } from './model/currency-model'; diff --git a/src/renderer/entities/price/lib/constants.ts b/src/renderer/entities/price/lib/constants.ts new file mode 100644 index 0000000000..8631638b58 --- /dev/null +++ b/src/renderer/entities/price/lib/constants.ts @@ -0,0 +1,6 @@ +import { PriceApiProvider } from './types'; + +export const DEFAULT_FIAT_FLAG = false; +export const DEFAULT_CURRENCY_CODE = 'usd'; +export const DEFAULT_FIAT_PROVIDER = PriceApiProvider.COINGEKO; +export const DEFAULT_ASSETS_PRICES = {}; diff --git a/src/renderer/entities/price/lib/types.ts b/src/renderer/entities/price/lib/types.ts new file mode 100644 index 0000000000..977633b110 --- /dev/null +++ b/src/renderer/entities/price/lib/types.ts @@ -0,0 +1,3 @@ +export const enum PriceApiProvider { + COINGEKO = 'coingeko', +} diff --git a/src/renderer/entities/price/model/__tests__/currency-model.test.ts b/src/renderer/entities/price/model/__tests__/currency-model.test.ts new file mode 100644 index 0000000000..e69ba7042f --- /dev/null +++ b/src/renderer/entities/price/model/__tests__/currency-model.test.ts @@ -0,0 +1,61 @@ +import { fork, allSettled } from 'effector'; + +import { kernelModel } from '@renderer/shared/core'; +import { currencyModel } from '../currency-model'; +import { fiatService, CurrencyItem } from '@renderer/shared/api/price-provider'; + +describe('entities/price/model/currency-model', () => { + const config: CurrencyItem[] = [ + { + code: 'EUR', + name: 'Euro', + symbol: '€', + category: 'fiat', + popular: true, + id: 1, + coingeckoId: 'eur', + }, + { + code: 'USD', + name: 'United States Dollar', + symbol: '$', + category: 'fiat', + popular: true, + id: 0, + coingeckoId: 'usd', + }, + ]; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should setup $currencyConfig on app start', async () => { + jest.spyOn(fiatService, 'getCurrencyConfig').mockReturnValue(config); + + const scope = fork(); + expect(scope.getState(currencyModel.$currencyConfig)).toEqual([]); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(currencyModel.$currencyConfig)).toEqual(config); + }); + + test('should setup $activeCurrency on app start', async () => { + jest.spyOn(fiatService, 'getCurrencyConfig').mockReturnValue(config); + jest.spyOn(fiatService, 'getActiveCurrencyCode').mockReturnValue('usd'); + + const scope = fork(); + expect(scope.getState(currencyModel.$activeCurrency)).toBeNull(); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(currencyModel.$activeCurrency)).toEqual(config[1]); + }); + + test('should change $activeCurrency when currencyChanged', async () => { + jest.spyOn(fiatService, 'getCurrencyConfig').mockReturnValue(config); + + const scope = fork(); + await allSettled(kernelModel.events.appStarted, { scope }); + await allSettled(currencyModel.events.currencyChanged, { scope, params: 1 }); + + expect(scope.getState(currencyModel.$activeCurrency)).toEqual(config[0]); + }); +}); diff --git a/src/renderer/entities/price/model/__tests__/price-provider-model.test.ts b/src/renderer/entities/price/model/__tests__/price-provider-model.test.ts new file mode 100644 index 0000000000..d137f7adc4 --- /dev/null +++ b/src/renderer/entities/price/model/__tests__/price-provider-model.test.ts @@ -0,0 +1,77 @@ +import { fork, allSettled } from 'effector'; + +import { kernelModel } from '@renderer/shared/core'; +import { fiatService, PriceObject, coingekoService } from '@renderer/shared/api/price-provider'; +import { priceProviderModel } from '../price-provider-model'; +import { PriceApiProvider } from '../../lib/types'; +import { currencyModel } from '../currency-model'; + +describe('entities/price/model/price-provider-model', () => { + const prices: PriceObject = { + kusama: { + usd: { price: 19.24, change: -4.745815232356294 }, + }, + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should setup $fiatFlag on app start', async () => { + jest.spyOn(fiatService, 'getFiatFlag').mockReturnValue(true); + + const scope = fork(); + expect(scope.getState(priceProviderModel.$fiatFlag)).toBeNull(); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(priceProviderModel.$fiatFlag)).toEqual(true); + }); + + test('should setup $priceProvider on app start', async () => { + const provider = PriceApiProvider.COINGEKO; + jest.spyOn(fiatService, 'getPriceProvider').mockReturnValue(provider); + + const scope = fork(); + expect(scope.getState(priceProviderModel.$priceProvider)).toBeNull(); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(priceProviderModel.$priceProvider)).toEqual(provider); + }); + + test('should setup $assetsPrices on app start', async () => { + jest.spyOn(fiatService, 'getPriceProvider').mockReturnValue(null); + jest.spyOn(fiatService, 'getAssetsPrices').mockReturnValue(prices); + + const scope = fork(); + expect(scope.getState(priceProviderModel.$assetsPrices)).toBeNull(); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(priceProviderModel.$assetsPrices)).toEqual(prices); + }); + + test('should change $fiatFlag when fiatFlagChanged', async () => { + jest.spyOn(fiatService, 'getFiatFlag').mockReturnValue(true); + + const scope = fork(); + await allSettled(kernelModel.events.appStarted, { scope }); + await allSettled(priceProviderModel.events.fiatFlagChanged, { scope, params: false }); + expect(scope.getState(priceProviderModel.$fiatFlag)).toEqual(false); + }); + + test('should change $priceProvider when priceProviderChanged', async () => { + jest.spyOn(fiatService, 'getPriceProvider').mockReturnValue(PriceApiProvider.COINGEKO); + + const scope = fork(); + await allSettled(priceProviderModel.events.priceProviderChanged, { scope, params: 'my_provider' }); + expect(scope.getState(priceProviderModel.$priceProvider)).toEqual('my_provider'); + }); + + test('should fetch $assetsPrices when assetsPricesRequested', async () => { + jest.spyOn(coingekoService, 'getPrice').mockResolvedValue(prices); + + const scope = fork({ + values: new Map() + .set(priceProviderModel.$priceProvider, PriceApiProvider.COINGEKO) + .set(currencyModel.$activeCurrency, 'usd'), + }); + await allSettled(priceProviderModel.events.assetsPricesRequested, { scope, params: { includeRates: false } }); + expect(scope.getState(priceProviderModel.$assetsPrices)).toEqual(prices); + }); +}); diff --git a/src/renderer/entities/price/model/currency-model.ts b/src/renderer/entities/price/model/currency-model.ts new file mode 100644 index 0000000000..617f647874 --- /dev/null +++ b/src/renderer/entities/price/model/currency-model.ts @@ -0,0 +1,77 @@ +import { createEvent, createStore, createEffect, forward, sample } from 'effector'; + +import { kernelModel } from '@renderer/shared/core'; +import { CurrencyItem, fiatService } from '@renderer/shared/api/price-provider'; +import { DEFAULT_CURRENCY_CODE } from '../lib/constants'; + +const $currencyConfig = createStore([]); +const $activeCurrency = createStore(null); +const $activeCurrencyCode = createStore(null); + +const currencyChanged = createEvent(); + +const getCurrencyConfigFx = createEffect((): CurrencyItem[] => { + return fiatService.getCurrencyConfig(); +}); + +const getActiveCurrencyCodeFx = createEffect((): string => { + return fiatService.getActiveCurrencyCode(DEFAULT_CURRENCY_CODE); +}); + +const saveActiveCurrencyCodeFx = createEffect((currency: CurrencyItem) => { + fiatService.saveActiveCurrencyCode(currency.code); +}); + +type ChangeParams = { + id?: CurrencyItem['id']; + code?: CurrencyItem['code']; + config: CurrencyItem[]; +}; +const currencyChangedFx = createEffect(({ id, code, config }) => { + return config.find((currency) => { + const hasId = currency.id === id; + const hasCode = currency.code.toLowerCase() === code?.toLowerCase(); + + return hasId || hasCode; + }); +}); + +forward({ + from: kernelModel.events.appStarted, + to: [getActiveCurrencyCodeFx, getCurrencyConfigFx], +}); + +forward({ from: getActiveCurrencyCodeFx.doneData, to: $activeCurrencyCode }); + +forward({ from: getCurrencyConfigFx.doneData, to: $currencyConfig }); + +sample({ + clock: getCurrencyConfigFx.doneData, + source: $activeCurrencyCode, + filter: (code: CurrencyItem['code'] | null): code is CurrencyItem['code'] => Boolean(code), + fn: (code, config) => ({ code, config }), + target: currencyChangedFx, +}); + +sample({ + clock: currencyChanged, + source: $currencyConfig, + fn: (config, id) => ({ config, id }), + target: currencyChangedFx, +}); + +sample({ + clock: currencyChangedFx.doneData, + source: $activeCurrency, + filter: (prev, next) => prev?.id !== next?.id, + fn: (_, next) => next!, + target: [$activeCurrency, saveActiveCurrencyCodeFx], +}); + +export const currencyModel = { + $currencyConfig, + $activeCurrency, + events: { + currencyChanged, + }, +}; diff --git a/src/renderer/entities/price/model/price-provider-model.ts b/src/renderer/entities/price/model/price-provider-model.ts new file mode 100644 index 0000000000..30ebb38ed8 --- /dev/null +++ b/src/renderer/entities/price/model/price-provider-model.ts @@ -0,0 +1,99 @@ +import { createEvent, createStore, forward, createEffect, sample } from 'effector'; + +import { PriceApiProvider } from '../lib/types'; +import { DEFAULT_FIAT_PROVIDER, DEFAULT_ASSETS_PRICES, DEFAULT_FIAT_FLAG } from '../lib/constants'; +import { fiatService, coingekoService, PriceObject, PriceAdapter } from '@renderer/shared/api/price-provider'; +import { kernelModel } from '@renderer/shared/core'; +import { chainsService } from '@renderer/entities/network'; +import { nonNullable } from '@renderer/shared/lib/utils'; +import { currencyModel } from './currency-model'; + +const $fiatFlag = createStore(null); +const $priceProvider = createStore(null); +const $assetsPrices = createStore(null); + +const fiatFlagChanged = createEvent(); +const priceProviderChanged = createEvent(); +const assetsPricesRequested = createEvent<{ includeRates: boolean }>(); + +const getFiatFlagFx = createEffect((): boolean => { + return fiatService.getFiatFlag(DEFAULT_FIAT_FLAG); +}); + +const saveFiatFlagFx = createEffect((flag: boolean) => { + fiatService.saveFiatFlag(flag); +}); + +const getPriceProviderFx = createEffect((): PriceApiProvider => { + return fiatService.getPriceProvider(DEFAULT_FIAT_PROVIDER); +}); + +const savePriceProviderFx = createEffect((provider: string) => { + fiatService.savePriceProvider(provider); +}); + +type FetchPrices = { + provider: PriceApiProvider; + currencies: string[]; + includeRates: boolean; +}; +const fetchAssetsPricesFx = createEffect(({ provider, currencies, includeRates }) => { + const ProvidersMap: Record = { + [PriceApiProvider.COINGEKO]: coingekoService, + }; + + const priceIds = chainsService.getChainsData().reduce((acc, chain) => { + const ids = chain.assets.map((asset) => asset.priceId).filter(nonNullable); + acc.push(...ids); + + return acc; + }, []); + + return ProvidersMap[provider].getPrice(priceIds, currencies, includeRates); +}); + +const getAssetsPricesFx = createEffect((): PriceObject => { + return fiatService.getAssetsPrices(DEFAULT_ASSETS_PRICES); +}); + +const saveAssetsPricesFx = createEffect((prices: PriceObject) => { + fiatService.saveAssetsPrices(prices); +}); + +forward({ + from: kernelModel.events.appStarted, + to: [getFiatFlagFx, getPriceProviderFx, getAssetsPricesFx], +}); + +forward({ from: getFiatFlagFx.doneData, to: $fiatFlag }); + +forward({ from: getPriceProviderFx.doneData, to: $priceProvider }); + +forward({ from: getAssetsPricesFx.doneData, to: $assetsPrices }); + +sample({ + clock: [assetsPricesRequested, $priceProvider, currencyModel.$activeCurrency], + source: { provider: $priceProvider, currency: currencyModel.$activeCurrency }, + filter: ({ provider, currency }) => provider !== null && currency !== null, + fn: ({ provider, currency }) => { + return { provider: provider!, currencies: [currency!.coingeckoId], includeRates: true }; + }, + target: fetchAssetsPricesFx, +}); + +forward({ from: fiatFlagChanged, to: [$fiatFlag, saveFiatFlagFx] }); + +forward({ from: priceProviderChanged, to: [$priceProvider, savePriceProviderFx] }); + +forward({ from: fetchAssetsPricesFx.doneData, to: [$assetsPrices, saveAssetsPricesFx] }); + +export const priceProviderModel = { + $fiatFlag, + $priceProvider, + $assetsPrices, + events: { + fiatFlagChanged, + priceProviderChanged, + assetsPricesRequested, + }, +}; diff --git a/src/renderer/shared/api/local-storage/index.ts b/src/renderer/shared/api/local-storage/index.ts new file mode 100644 index 0000000000..722f91d12c --- /dev/null +++ b/src/renderer/shared/api/local-storage/index.ts @@ -0,0 +1 @@ +export { localStorageService } from './service/localStorageService'; diff --git a/src/renderer/shared/api/local-storage/service/localStorageService.ts b/src/renderer/shared/api/local-storage/service/localStorageService.ts new file mode 100644 index 0000000000..70632be252 --- /dev/null +++ b/src/renderer/shared/api/local-storage/service/localStorageService.ts @@ -0,0 +1,22 @@ +export const localStorageService = { + getFromStorage, + saveToStorage, +}; + +function getFromStorage(key: string, defaultValue: T): T { + const storageItem = localStorage.getItem(key); + + if (!storageItem) return defaultValue; + + try { + return storageItem ? JSON.parse(storageItem) : defaultValue; + } catch { + console.error(`🔸LocalStorageService - Could not retrieve item by key - ${key}`); + + return defaultValue; + } +} + +function saveToStorage(key: string, value: T) { + localStorage.setItem(key, JSON.stringify(value)); +} diff --git a/src/renderer/shared/api/price/coingecko/CoingeckoAdapter.test.ts b/src/renderer/shared/api/price-provider/__tests__/coingeckoService.test.ts similarity index 66% rename from src/renderer/shared/api/price/coingecko/CoingeckoAdapter.test.ts rename to src/renderer/shared/api/price-provider/__tests__/coingeckoService.test.ts index a59f77c25b..933d4021ef 100644 --- a/src/renderer/shared/api/price/coingecko/CoingeckoAdapter.test.ts +++ b/src/renderer/shared/api/price-provider/__tests__/coingeckoService.test.ts @@ -1,7 +1,7 @@ -import { useCoinGeckoAdapter } from './CoingeckoAdapter'; +import { coingekoService } from '../service/coingeckoService'; -describe('api/price/coingecko/CoinGeckoAdapter', () => { - test('get price from coingecko', async () => { +describe('shared/api/price-provider/services/coingekoService', () => { + test('get price-provider from coingecko', async () => { global.fetch = jest.fn(() => Promise.resolve({ json: () => @@ -22,15 +22,13 @@ describe('api/price/coingecko/CoinGeckoAdapter', () => { }), ) as jest.Mock; - const { getPrice } = useCoinGeckoAdapter(); - - const result = await getPrice(['kusama', 'polkadot'], ['usd', 'rub'], true); + const result = await coingekoService.getPrice(['kusama', 'polkadot'], ['usd', 'rub'], true); expect(result['kusama']['usd'].price).toBeDefined(); expect(result['polkadot']['rub'].change).toBeDefined(); }); - test('get price from coingecko', async () => { + test('get history data from coingecko', async () => { global.fetch = jest.fn(() => Promise.resolve({ json: () => @@ -44,10 +42,8 @@ describe('api/price/coingecko/CoinGeckoAdapter', () => { }), ) as jest.Mock; - const { getHistoryData } = useCoinGeckoAdapter(); - - const result = await getHistoryData('kusama', 'usd', 1692700000, 1692701000); + const result = await coingekoService.getHistoryData('kusama', 'usd', 1692700000, 1692701000); - expect(result.length).toBe(3); + expect(result.length).toEqual(3); }); }); diff --git a/src/renderer/shared/api/price-provider/__tests__/fiatService.test.ts b/src/renderer/shared/api/price-provider/__tests__/fiatService.test.ts new file mode 100644 index 0000000000..35f35c90bb --- /dev/null +++ b/src/renderer/shared/api/price-provider/__tests__/fiatService.test.ts @@ -0,0 +1,80 @@ +import { fiatService } from '../service/fiatService'; +import { localStorageService } from '@renderer/shared/api/local-storage'; +import { CURRENCY_CODE_KEY, FIAT_FLAG_KEY, PRICE_PROVIDER_KEY, ASSETS_PRICES_KEY } from '../common/constants'; + +describe('shared/api/price-provider/services/fiatService', () => { + const spyGetFn = (value: any) => jest.spyOn(localStorageService, 'getFromStorage').mockReturnValue(value); + const spySaveFn = () => jest.spyOn(localStorageService, 'saveToStorage').mockImplementation(); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('getActiveCurrencyCode should return value', () => { + const spyGet = spyGetFn('usd'); + + const defaultValue = 'code'; + fiatService.getActiveCurrencyCode(defaultValue); + expect(spyGet).toHaveBeenCalledWith(CURRENCY_CODE_KEY, defaultValue); + expect(spyGet).toReturnWith('usd'); + }); + + test('saveActiveCurrencyCode should save value', () => { + const spySave = spySaveFn(); + + const value = 'usd'; + fiatService.saveActiveCurrencyCode(value); + expect(spySave).toHaveBeenCalledWith(CURRENCY_CODE_KEY, value); + }); + + test('getFiatFlag should return value', () => { + const spyGet = spyGetFn(true); + + const defaultValue = false; + fiatService.getFiatFlag(defaultValue); + expect(spyGet).toHaveBeenCalledWith(FIAT_FLAG_KEY, defaultValue); + expect(spyGet).toReturnWith(true); + }); + + test('saveFiatFlag should save value', () => { + const spyGet = spySaveFn(); + + const value = false; + fiatService.saveFiatFlag(value); + expect(spyGet).toHaveBeenCalledWith(FIAT_FLAG_KEY, value); + }); + + test('getPriceProvider should return value', () => { + const spyGet = spyGetFn('coingeko'); + + const defaultValue = 'coinbase'; + fiatService.getPriceProvider(defaultValue); + expect(spyGet).toHaveBeenCalledWith(PRICE_PROVIDER_KEY, defaultValue); + expect(spyGet).toReturnWith('coingeko'); + }); + + test('saveFiatProvider should save value', () => { + const spyGet = spySaveFn(); + + const value = 'coinbase'; + fiatService.savePriceProvider(value); + expect(spyGet).toHaveBeenCalledWith(PRICE_PROVIDER_KEY, value); + }); + + test('getAssetsPrices should return value', () => { + const spyGet = spyGetFn({ acala: '100' }); + + const defaultValue = { polkadot: '200' }; + fiatService.getAssetsPrices(defaultValue); + expect(spyGet).toHaveBeenCalledWith(ASSETS_PRICES_KEY, defaultValue); + expect(spyGet).toReturnWith({ acala: '100' }); + }); + + test('saveAssetsPrices should save value', () => { + const spyGet = spySaveFn(); + + const value = { polkadot: '200' }; + fiatService.saveAssetsPrices(value); + expect(spyGet).toHaveBeenCalledWith(ASSETS_PRICES_KEY, value); + }); +}); diff --git a/src/renderer/shared/api/price/common/utils.test.ts b/src/renderer/shared/api/price-provider/__tests__/utils.test.ts similarity index 93% rename from src/renderer/shared/api/price/common/utils.test.ts rename to src/renderer/shared/api/price-provider/__tests__/utils.test.ts index 0bd97faea1..04ef94b414 100644 --- a/src/renderer/shared/api/price/common/utils.test.ts +++ b/src/renderer/shared/api/price-provider/__tests__/utils.test.ts @@ -1,6 +1,6 @@ -import { convertPriceToObjectView, convertPriceToDBView, getCurrencyChangeKey } from './utils'; +import { convertPriceToObjectView, convertPriceToDBView, getCurrencyChangeKey } from '../common/utils'; -describe('api/price/common', () => { +describe('shared/api/price-provider/common/utils', () => { test('get correct change key', () => { const result = getCurrencyChangeKey('polkadot'); diff --git a/src/renderer/shared/api/price-provider/common/constants.ts b/src/renderer/shared/api/price-provider/common/constants.ts new file mode 100644 index 0000000000..d137549499 --- /dev/null +++ b/src/renderer/shared/api/price-provider/common/constants.ts @@ -0,0 +1,5 @@ +export const COINGECKO_URL = 'https://api.coingecko.com/api/v3'; +export const CURRENCY_CODE_KEY = 'currency_code'; +export const FIAT_FLAG_KEY = 'fiat_flag'; +export const PRICE_PROVIDER_KEY = 'price_provider'; +export const ASSETS_PRICES_KEY = 'assets_prices'; diff --git a/src/renderer/shared/api/price/common/types.ts b/src/renderer/shared/api/price-provider/common/types.ts similarity index 78% rename from src/renderer/shared/api/price/common/types.ts rename to src/renderer/shared/api/price-provider/common/types.ts index 1c25c6c485..b941f5d3f2 100644 --- a/src/renderer/shared/api/price/common/types.ts +++ b/src/renderer/shared/api/price-provider/common/types.ts @@ -19,3 +19,13 @@ export type PriceAdapter = { getPrice: (ids: AssetId[], currencies: Currency[], includeRateChange: boolean) => Promise; getHistoryData: (id: AssetId, currency: Currency, from: number, to: number) => Promise; }; + +export type CurrencyItem = { + id: number; + code: string; + name: string; + symbol?: string; + category: 'fiat' | 'crypto'; + popular: boolean; + coingeckoId: string; +}; diff --git a/src/renderer/shared/api/price/common/utils.ts b/src/renderer/shared/api/price-provider/common/utils.ts similarity index 65% rename from src/renderer/shared/api/price/common/utils.ts rename to src/renderer/shared/api/price-provider/common/utils.ts index ab454bdbd0..fedf6e7c32 100644 --- a/src/renderer/shared/api/price/common/utils.ts +++ b/src/renderer/shared/api/price-provider/common/utils.ts @@ -1,10 +1,10 @@ import { PriceObject, PriceDB } from './types'; -export const getCurrencyChangeKey = (currency: string): string => { +export function getCurrencyChangeKey(currency: string): string { return `${currency}_24h_change`; -}; +} -export const convertPriceToDBView = (price: PriceObject): PriceDB[] => { +export function convertPriceToDBView(price: PriceObject): PriceDB[] { const priceDB: PriceDB[] = []; Object.entries(price).forEach(([assetId, assetPrice]) => { @@ -19,19 +19,16 @@ export const convertPriceToDBView = (price: PriceObject): PriceDB[] => { }); return priceDB; -}; +} -export const convertPriceToObjectView = (prices: PriceDB[]): PriceObject => { +export function convertPriceToObjectView(prices: PriceDB[]): PriceObject { return prices.reduce((result, { assetId, currency, price, change }) => { if (!result[assetId]) { result[assetId] = {}; } - result[assetId][currency] = { - price, - change, - }; + result[assetId][currency] = { price, change }; return result; }, {}); -}; +} diff --git a/src/renderer/shared/api/price-provider/index.ts b/src/renderer/shared/api/price-provider/index.ts new file mode 100644 index 0000000000..c046960a7d --- /dev/null +++ b/src/renderer/shared/api/price-provider/index.ts @@ -0,0 +1,3 @@ +export { coingekoService } from './service/coingeckoService'; +export { fiatService } from './service/fiatService'; +export type { CurrencyItem, PriceAdapter, PriceObject } from './common/types'; diff --git a/src/renderer/shared/api/price-provider/service/coingeckoService.ts b/src/renderer/shared/api/price-provider/service/coingeckoService.ts new file mode 100644 index 0000000000..12b7c808e4 --- /dev/null +++ b/src/renderer/shared/api/price-provider/service/coingeckoService.ts @@ -0,0 +1,47 @@ +import { AssetId, Currency, PriceObject, PriceAdapter, PriceItem, PriceRange } from '../common/types'; +import { getCurrencyChangeKey } from '../common/utils'; +import { COINGECKO_URL } from '../common/constants'; + +export const coingekoService: PriceAdapter = { + getPrice, + getHistoryData, +}; + +async function getPrice(ids: AssetId[], currencies: Currency[], includeRateChange: boolean): Promise { + const url = new URL(`${COINGECKO_URL}/simple/price`); + url.search = new URLSearchParams({ + ids: ids.join(','), + vs_currencies: currencies.join(','), + include_24hr_change: includeRateChange.toString(), + }).toString(); + + const response = await fetch(url); + const data = await response.json(); + + return ids.reduce((acc, assetId) => { + acc[assetId] = currencies.reduce>((accPrice, currency) => { + accPrice[currency] = { + price: data[assetId][currency], + change: data[assetId][getCurrencyChangeKey(currency)], + }; + + return accPrice; + }, {}); + + return acc; + }, {}); +} + +async function getHistoryData(id: string, currency: string, from: number, to: number): Promise { + const url = new URL(`${COINGECKO_URL}/coins/${id}/market_chart/range`); + url.search = new URLSearchParams({ + vs_currency: currency, + from: from.toString(), + to: to.toString(), + }).toString(); + + const response = await fetch(url); + const data = await response.json(); + + return data.prices; +} diff --git a/src/renderer/shared/api/price-provider/service/fiatService.ts b/src/renderer/shared/api/price-provider/service/fiatService.ts new file mode 100644 index 0000000000..16ae148096 --- /dev/null +++ b/src/renderer/shared/api/price-provider/service/fiatService.ts @@ -0,0 +1,52 @@ +import CURRENCY from '@renderer/assets/currency/currencies.json'; +import { localStorageService } from '@renderer/shared/api/local-storage'; +import { CurrencyItem } from '../common/types'; +import { CURRENCY_CODE_KEY, FIAT_FLAG_KEY, PRICE_PROVIDER_KEY, ASSETS_PRICES_KEY } from '../common/constants'; + +export const fiatService = { + getCurrencyConfig, + getActiveCurrencyCode, + saveActiveCurrencyCode, + getFiatFlag, + saveFiatFlag, + getPriceProvider, + savePriceProvider, + getAssetsPrices, + saveAssetsPrices, +}; + +function getCurrencyConfig(): CurrencyItem[] { + return CURRENCY as CurrencyItem[]; +} + +function getActiveCurrencyCode(defaultCode: string): string { + return localStorageService.getFromStorage(CURRENCY_CODE_KEY, defaultCode.toLowerCase()); +} + +function saveActiveCurrencyCode(code: string) { + localStorageService.saveToStorage(CURRENCY_CODE_KEY, code.toLowerCase()); +} + +function getFiatFlag(defaultFlag: boolean): boolean { + return localStorageService.getFromStorage(FIAT_FLAG_KEY, defaultFlag); +} + +function saveFiatFlag(flag: boolean) { + localStorageService.saveToStorage(FIAT_FLAG_KEY, flag); +} + +function getPriceProvider(defaultFiatProvider: T): T { + return localStorageService.getFromStorage(PRICE_PROVIDER_KEY, defaultFiatProvider); +} + +function savePriceProvider(provider: string) { + localStorageService.saveToStorage(PRICE_PROVIDER_KEY, provider); +} + +function getAssetsPrices(defaultPrices: T): T { + return localStorageService.getFromStorage(ASSETS_PRICES_KEY, defaultPrices); +} + +function saveAssetsPrices(prices: T) { + localStorageService.saveToStorage(ASSETS_PRICES_KEY, prices); +} diff --git a/src/renderer/shared/api/price/coingecko/CoingeckoAdapter.ts b/src/renderer/shared/api/price/coingecko/CoingeckoAdapter.ts deleted file mode 100644 index 5571e6b173..0000000000 --- a/src/renderer/shared/api/price/coingecko/CoingeckoAdapter.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AssetId, Currency, PriceObject, PriceAdapter, PriceItem, PriceRange } from '../common/types'; -import { getCurrencyChangeKey } from '../common/utils'; -import { COINGECKO_URL } from './consts'; - -export const useCoinGeckoAdapter = (): PriceAdapter => { - const getPrice = async (ids: AssetId[], currencies: Currency[], includeRateChange: boolean): Promise => { - const url = new URL(`${COINGECKO_URL}/simple/price`); - url.search = new URLSearchParams({ - ids: ids.join(','), - vs_currencies: currencies.join(','), - include_24hr_change: includeRateChange.toString(), - }).toString(); - - const response = await fetch(url); - - const data = await response.json(); - - return ids.reduce((acc, assetId) => { - acc[assetId] = currencies.reduce>((accPrice, currency) => { - accPrice[currency] = { - price: data[assetId][currency], - change: data[assetId][getCurrencyChangeKey(currency)], - }; - - return accPrice; - }, {}); - - return acc; - }, {}); - }; - - const getHistoryData = async (id: string, currency: string, from: number, to: number): Promise => { - const url = new URL(`${COINGECKO_URL}/coins/${id}/market_chart/range`); - url.search = new URLSearchParams({ - vs_currency: currency, - from: from.toString(), - to: to.toString(), - }).toString(); - - const response = await fetch(url); - - const data = await response.json(); - - return data.prices; - }; - - return { - getPrice, - getHistoryData, - }; -}; diff --git a/src/renderer/shared/api/price/coingecko/consts.ts b/src/renderer/shared/api/price/coingecko/consts.ts deleted file mode 100644 index a6fb2a747d..0000000000 --- a/src/renderer/shared/api/price/coingecko/consts.ts +++ /dev/null @@ -1 +0,0 @@ -export const COINGECKO_URL = 'https://api.coingecko.com/api/v3'; diff --git a/src/renderer/shared/api/price/index.ts b/src/renderer/shared/api/price/index.ts deleted file mode 100644 index b2dd9830df..0000000000 --- a/src/renderer/shared/api/price/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './coingecko/CoingeckoAdapter'; -export * from './common/types'; -export * from './common/utils'; diff --git a/src/renderer/shared/core/index.ts b/src/renderer/shared/core/index.ts index a66d38a5a4..36fbc281e9 100644 --- a/src/renderer/shared/core/index.ts +++ b/src/renderer/shared/core/index.ts @@ -1 +1 @@ -export * as kernelModel from './model/kernel-model'; +export { kernelModel } from './model/kernel-model'; diff --git a/src/renderer/shared/core/model/kernel-model.ts b/src/renderer/shared/core/model/kernel-model.ts index 35c883166d..b827920a77 100644 --- a/src/renderer/shared/core/model/kernel-model.ts +++ b/src/renderer/shared/core/model/kernel-model.ts @@ -2,6 +2,8 @@ import { createEvent } from 'effector'; const appStarted = createEvent(); -export const events = { - appStarted, +export const kernelModel = { + events: { + appStarted, + }, };