diff --git a/.changeset/slow-drinks-invite.md b/.changeset/slow-drinks-invite.md new file mode 100644 index 000000000..f4b483059 --- /dev/null +++ b/.changeset/slow-drinks-invite.md @@ -0,0 +1,5 @@ +--- +"frontend": patch +--- + +SOV-4462: D2 charts on Convert page diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index dd7c0e809..8760e832f 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -1,6 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies +/public/charting_library +/public/datafeeds /node_modules /.pnp .pnp.js diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 20d26da55..88dc2a22b 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -9,6 +9,7 @@ "@loadable/component": "5.15.2", "@sovryn-zero/lib-base": "0.2.1", "@sovryn-zero/lib-ethers": "0.2.5", + "@sovryn/charting-library": "2.0.0", "@sovryn/contracts": "*", "@sovryn/ethers-provider": "*", "@sovryn/onboard-bitget": "1.0.1", @@ -58,6 +59,7 @@ "rxjs": "7.5.6", "sanitize-html": "2.11.0", "socket.io-client": "4.5.4", + "storage-factory": "^0.2.1", "utf8": "^3.0.0", "zustand": "^4.5.1" }, @@ -92,16 +94,17 @@ }, "scripts": { "predev": "yarn generate:graphql", - "dev": "craco start", + "dev": "yarn copy-libs && craco start", "prebuild": "yarn generate:graphql", - "build": "craco build", + "build": "yarn copy-libs && craco build", "test": "craco test --watchAll=false --passWithNoTests", "test:staged": "craco test --watchAll=false --passWithNoTests --bail --onlyChanged", "lint": "eslint -c .eslintrc.js ./", "generate:graphql": "graphql-codegen", "generate:graphql:fetch:testnet": "env-cmd -f .env.development.local graphql-codegen -c codegen.fetch.yml", "generate:graphql:fetch:mainnet": "env-cmd -f .env.staging graphql-codegen -c codegen.fetch.yml", - "find-deadcode": "ts-prune -s .generated.tsx | grep -v '(used in module)'" + "find-deadcode": "ts-prune -s .generated.tsx | grep -v '(used in module)'", + "copy-libs": "node scripts/copyLibs.js" }, "browserslist": [ "last 5 chrome version", diff --git a/apps/frontend/scripts/copyLibs.js b/apps/frontend/scripts/copyLibs.js new file mode 100644 index 000000000..116f38d31 --- /dev/null +++ b/apps/frontend/scripts/copyLibs.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); +const fsp = fs.promises; + +async function deleteDir(dir) { + if (fs.existsSync(dir)) { + await fsp.rmdir(dir, { recursive: true }); + console.log(`Deleted directory: ${dir}`); + } +} + +async function copyDir(src, dest) { + await fsp.mkdir(dest, { recursive: true }); + const entries = await fsp.readdir(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + await copyDir(srcPath, destPath); + } else { + await fsp.copyFile(srcPath, destPath); + } + } +} + +async function copyLibs() { + const chartingLibrarySrc = path.resolve( + __dirname, + '../../../node_modules/@sovryn/charting-library/public/charting_library', + ); + const chartingLibraryDest = path.resolve( + __dirname, + '../public/charting_library', + ); + + const datafeedsSrc = path.resolve( + __dirname, + '../../../node_modules/@sovryn/charting-library/public/datafeeds', + ); + const datafeedsDest = path.resolve(__dirname, '../public/datafeeds'); + + if (fs.existsSync(chartingLibrarySrc)) { + await deleteDir(chartingLibraryDest); + await copyDir(chartingLibrarySrc, chartingLibraryDest); + console.log('Charting Library copied.'); + } else { + console.error('Charting Library source not found.'); + } + + if (fs.existsSync(datafeedsSrc)) { + await deleteDir(datafeedsDest); + await copyDir(datafeedsSrc, datafeedsDest); + console.log('Datafeeds copied.'); + } else { + console.error('Datafeeds source not found.'); + } +} + +copyLibs(); diff --git a/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.constants.ts b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.constants.ts new file mode 100644 index 000000000..953fb522b --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.constants.ts @@ -0,0 +1,64 @@ +import { storageFactory } from 'storage-factory'; + +import { ResolutionString } from '@sovryn/charting-library/src/charting_library'; + +import { INDEXER_SERVICE } from '../../../constants/infrastructure'; +import { Environments } from '../../../types/global'; +import { CandleDuration } from './dictionary'; + +export const REFRESH_RATE = 15 * 1e3; +export const MAXIMUM_CHUNK_SIZE = 1e3; +export const endTimeCache = new Map(); +export const supportedResolutions = [ + '1', + '5', + '10', + '15', + '30', + '60', + '240', + '720', + '1D', + '3D', + '1W', + '1M', +] as ResolutionString[]; + +export const resolutionMap: { [key: string]: CandleDuration } = { + '1': CandleDuration.M_1, + '5': CandleDuration.M_1, + '10': CandleDuration.M_10, + '15': CandleDuration.M_15, + '30': CandleDuration.M_30, + '60': CandleDuration.H_1, + H: CandleDuration.H_1, + '240': CandleDuration.H_4, + '720': CandleDuration.H_12, + '1440': CandleDuration.D_1, + D: CandleDuration.D_1, + '1D': CandleDuration.D_1, + '3D': CandleDuration.D_3, + W: CandleDuration.W_1, + '1W': CandleDuration.W_1, + M: CandleDuration.D_30, + '1M': CandleDuration.D_30, +}; + +export const local = storageFactory(() => localStorage); + +export const chartStorageKey = 'sovryn.charts'; + +export const config = { + exchanges: [], + symbols_types: [], + supported_resolutions: supportedResolutions, + supports_time: false, +}; + +export const SOVRYN_INDEXER_MAINNET = `${ + INDEXER_SERVICE[Environments.Mainnet] +}chart`; + +export const SOVRYN_INDEXER_TESTNET = `${ + INDEXER_SERVICE[Environments.Testnet] +}chart`; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.tsx b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.tsx new file mode 100644 index 000000000..c453983e2 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.tsx @@ -0,0 +1,82 @@ +import { useApolloClient } from '@apollo/client'; + +import React, { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { + ChartingLibraryWidgetOptions, + IChartingLibraryWidget, + ResolutionString, + widget, +} from '@sovryn/charting-library/src/charting_library'; +import { noop } from '@sovryn/ui'; + +import { SeriesStyle, TradingChartProps } from './TradingChart.types'; +import Datafeed from './datafeed'; + +export const TradingChart: FC = ({ pair }) => { + const chartContainerRef = + useRef() as React.MutableRefObject; + + const [hasCharts, setHasCharts] = useState(false); + const [chart, setChart] = useState(null); + const client = useApolloClient(); + + useEffect(() => { + try { + const widgetOptions: ChartingLibraryWidgetOptions = { + symbol: pair, + datafeed: Datafeed(client), + interval: '1D' as ResolutionString, + container: chartContainerRef.current, + library_path: '/charting_library/', + load_last_chart: true, //last chart layout (if present) + theme: 'dark', + locale: 'en', + disabled_features: ['header_symbol_search', 'header_compare'], + enabled_features: [ + 'study_templates', + 'side_toolbar_in_fullscreen_mode', + ], + charts_storage_url: 'https://saveload.tradingview.com', + charts_storage_api_version: '1.1', + client_id: 'tradingview.com', + user_id: 'public_user_id', + fullscreen: false, + autosize: true, + studies_overrides: {}, + }; + + const myChart = new widget(widgetOptions); + setChart(myChart); + myChart.onChartReady(() => { + setHasCharts(true); + }); + + return () => { + myChart.remove(); + setHasCharts(false); + setChart(null); + }; + } catch (e) { + console.error(e); + setHasCharts(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client]); + + useLayoutEffect(() => { + if (chart && hasCharts) { + chart.chart().resetData(); + + chart.chart().setChartType(SeriesStyle.Candles as number); + + chart.chart().setSymbol(pair, noop); + } + }, [chart, hasCharts, pair]); + + return ( +
+
+
+ ); +}; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.types.ts b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.types.ts new file mode 100644 index 000000000..4ebab009e --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.types.ts @@ -0,0 +1,80 @@ +export type TradingChartProps = { + pair: string; +}; + +export enum SeriesStyle { + Bars = 0, + Candles = 1, + Line = 2, + Area = 3, + HeikenAshi = 8, + HollowCandles = 9, + Renko = 4, + Kagi = 5, + PointAndFigure = 6, + LineBreak = 7, +} + +export type Bar = { + time: number; + low: number; + high: number; + open: number; + close: number; + volume?: number; +}; + +export type CandleSticksResponse = { + id: string; + open: number; + high: number; + low: number; + close: number; + totalVolume: number; + periodStartUnix: number; +}; + +export type TimestampChunk = { + from: number; + to: number; +}; + +export type Candle = { + open: string; + high: string; + low: string; + close: string; + totalVolume: string; + periodStartUnix: string; +}; + +export type StoredCharts = { + [id: string]: ChartData; +}; + +export type ChartData = { + id: string; + name: string; + symbol: string; + resolution: string; + content: string; + timestamp: number; +}; + +export type StoredTemplates = { + [id: string]: TemplateData; +}; + +export type TemplateData = { + name: string; + content: string; +}; + +export type SubItem = { + symbolInfo: any; + subscribeUID: string; //e.g. SOV/USDT_10 + resolution: string; + lastBar: Bar; + handler: Function; + timer?: number; +}; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.utils.tsx b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.utils.tsx new file mode 100644 index 000000000..b9e7eb1c0 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.utils.tsx @@ -0,0 +1,66 @@ +import EventEmitter from 'events'; + +import { getContract } from '@sovryn/contracts'; + +import { getCurrentChain } from '../../../hooks/useChainStore'; +import { isMainnet } from '../../../utils/helpers'; +import { + SOVRYN_INDEXER_MAINNET, + SOVRYN_INDEXER_TESTNET, +} from './TradingChart.constants'; +import { Bar } from './TradingChart.types'; +import { CandleDetails } from './dictionary'; + +const pushes: Record = {}; +const hub = new EventEmitter({ captureRejections: true }); + +export const pushPrice = (symbol: string, price: number) => { + pushes[symbol] = price; + hub.emit(symbol, price); +}; + +const indexerBaseUrl = isMainnet() + ? SOVRYN_INDEXER_MAINNET + : SOVRYN_INDEXER_TESTNET; + +export const queryCandles = async ( + chainId: number, + candleDetails: CandleDetails, + baseToken: string, + quoteToken: string, + startTime: number, + endTime: number, +) => { + try { + const fullIndexerUrl = `${indexerBaseUrl}?chainId=${chainId}&base=${baseToken}"e=${quoteToken}&start=${startTime}&end=${endTime}&timeframe=${candleDetails.candleSymbol}`; + + const result = await (await fetch(fullIndexerUrl)).json(); + + const bars: Bar[] = result.data.reverse().map(item => ({ + time: Number(item.date) * 1000, + low: item.low, + high: item.high, + open: item.open, + close: item.close, + })); + + return bars; + } catch (error) { + console.error(error); + throw new Error(`Request error: ${error}`); + } +}; + +const getTokenAddress = async (asset: string) => { + const { address } = await getContract(asset, 'assets', getCurrentChain()); + return address; +}; + +export const getTokensFromSymbol = (symbol: string) => { + let [base, quote, chainId] = symbol.split('/'); + return { + baseToken: getTokenAddress(base), + quoteToken: getTokenAddress(quote), + chainId: Number(chainId), + }; +}; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/datafeed.ts b/apps/frontend/src/app/2_molecules/TradingChart/datafeed.ts new file mode 100644 index 000000000..13b6d8e37 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/datafeed.ts @@ -0,0 +1,166 @@ +import { ApolloClient } from '@apollo/client'; + +import { + ChartingLibraryWidgetOptions, + DatafeedErrorCallback, + HistoryCallback, + LibrarySymbolInfo, + PeriodParams, + ResolutionString, +} from '@sovryn/charting-library/src/charting_library'; + +import { + config, + resolutionMap, + supportedResolutions, +} from './TradingChart.constants'; +import { Bar } from './TradingChart.types'; +import { + getTokensFromSymbol, + pushPrice, + queryCandles, +} from './TradingChart.utils'; +import { CandleDuration, TradingCandleDictionary } from './dictionary'; +import { stream } from './streaming'; + +const newestBarsCache = new Map(); +const oldestBarsCache = new Map(); + +const tradingChartDataFeeds = ( + graphqlClient: ApolloClient, +): ChartingLibraryWidgetOptions['datafeed'] => ({ + onReady: callback => setTimeout(() => callback(config)), + searchSymbols: () => {}, + resolveSymbol: async (symbolName, onSymbolResolvedCallback) => { + const [base, quote] = symbolName.split('/'); + const symbolInfo: LibrarySymbolInfo = { + name: symbolName, + description: '', + type: 'crypto', + exchange: '', + listed_exchange: '', + format: 'price', + volume_precision: 6, + session: '24x7', + timezone: 'Etc/UTC', + ticker: `${base}/${quote}`, + minmov: 1, + pricescale: 10 ** 8, + has_intraday: true, + intraday_multipliers: [ + '1', + '5', + '10', + '15', + '30', + '60', + '240', + '720', + '1440', + '4320', + '10080', + '43200', + ], + supported_resolutions: supportedResolutions, + has_empty_bars: true, + has_daily: true, + has_weekly_and_monthly: true, + data_status: 'streaming', + }; + + setTimeout(() => onSymbolResolvedCallback(symbolInfo)); + }, + getBars: async ( + symbolInfo: LibrarySymbolInfo, + resolution: ResolutionString, + periodParams: PeriodParams, + onResult: HistoryCallback, + onError: DatafeedErrorCallback, + ) => { + const { from, to, firstDataRequest } = periodParams; + const candleDuration: CandleDuration = resolutionMap[resolution]; + const candleDetails = TradingCandleDictionary.get(candleDuration); + + try { + const { baseToken, quoteToken, chainId } = getTokensFromSymbol( + symbolInfo.name, + ); + + let items = await queryCandles( + chainId, + candleDetails, + await baseToken, + await quoteToken, + from, + to, + ); + + if (!items || items.length === 0) { + onResult([], { noData: true }); + return; + } + + if (firstDataRequest) { + newestBarsCache.set(symbolInfo.name, { ...items[items.length - 1] }); + oldestBarsCache.set(symbolInfo.name, { ...items[0] }); + } + + const lastBar = newestBarsCache.get(symbolInfo.name); + const newestBar = items[items.length - 1]; + + if (!lastBar) { + newestBarsCache.set(symbolInfo.name, newestBar); + } + + if (lastBar && newestBar && newestBar?.time >= lastBar.time) { + newestBarsCache.set(symbolInfo.name, newestBar); + pushPrice(`${baseToken}/${quoteToken}`, newestBar.close); + } + + const oldestBar = oldestBarsCache.get(symbolInfo.name); + const currentOldest = items[0]; + + if (!oldestBar) { + oldestBarsCache.set(symbolInfo.name, currentOldest); + } + + if (oldestBar && currentOldest && currentOldest?.time <= oldestBar.time) { + oldestBarsCache.set(symbolInfo.name, currentOldest); + } + + let bars: Bar[] = []; + + items.forEach(bar => { + if (bar.time >= from * 1000 && bar.time < to * 1000) { + bars = [...bars, bar]; + } + }); + + onResult(bars, { noData: false }); + } catch (error) { + console.log('error', error); + onError(error.message); + } + }, + subscribeBars: ( + symbolInfo, + resolution, + onRealtimeCallback, + subscribeUID, + onResetCacheNeededCallback, + ) => { + const newestBar = newestBarsCache.get(symbolInfo.name); + stream.subscribeOnStream( + graphqlClient, + symbolInfo, + resolution, + onRealtimeCallback, + subscribeUID, + onResetCacheNeededCallback, + newestBar, + ); + }, + unsubscribeBars: subscriberUID => stream.unsubscribeFromStream(subscriberUID), +}); + +export default tradingChartDataFeeds; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/dictionary.ts b/apps/frontend/src/app/2_molecules/TradingChart/dictionary.ts new file mode 100644 index 000000000..2baa433e2 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/dictionary.ts @@ -0,0 +1,130 @@ +export enum CandleDuration { + M_1 = 'M_1', + M_10 = 'M_10', + M_15 = 'M_15', + M_30 = 'M_30', + H_1 = 'H_1', + H_4 = 'H_4', + H_12 = 'H_12', + D_1 = 'D_1', + D_3 = 'D_3', + W_1 = 'W_1', + D_30 = 'D_30', +} + +export class CandleDetails { + /** TODO: Add default number of candles or default startTime */ + constructor( + public entityName: string, + public resolutionBack: 'D' | 'M', + public intervalBack: number, + public startDaysFromNow: number, // Number of days back to start from in default query + public candleSeconds: number, + public candleSymbol: string, + ) { + this.entityName = entityName; + this.resolutionBack = resolutionBack; + this.intervalBack = intervalBack; + this.startDaysFromNow = startDaysFromNow; + this.candleSeconds = candleSeconds; + this.candleSymbol = candleSymbol; + } +} + +export class TradingCandleDictionary { + public static candles: Map = new Map< + CandleDuration, + CandleDetails + >([ + [ + CandleDuration.M_1, + new CandleDetails('candleSticksMinutes', 'D', 1, 5, 60, '1m'), + ], + [ + CandleDuration.M_10, + new CandleDetails('candleSticksTenMinutes', 'D', 3, 5, 60 * 10, '10m'), + ], + [ + CandleDuration.M_15, + new CandleDetails( + 'candleSticksFifteenMinutes', + 'D', + 3, + 5, + 60 * 15, + '15m', + ), + ], + [ + CandleDuration.M_30, + new CandleDetails('candleSticksThirtyMinutes', 'D', 3, 5, 60 * 30, '30m'), + ], + [ + CandleDuration.H_1, + new CandleDetails('candleSticksHours', 'D', 5, 5, 60 * 60, '1h'), + ], + [ + CandleDuration.H_4, + new CandleDetails( + 'candleSticksFourHours', + 'D', + 10, + 10, + 60 * 60 * 4, + '4h', + ), + ], + [ + CandleDuration.H_12, + new CandleDetails( + 'candleSticksTwelveHours', + 'D', + 10, + 10, + 60 * 60 * 12, + '12h', + ), + ], + [ + CandleDuration.D_1, + new CandleDetails('candleSticksDays', 'D', 90, 90, 60 * 60 * 24, '1d'), + ], + [ + CandleDuration.D_3, + new CandleDetails( + 'candleSticksThreeDays', + 'D', + 90, + 90, + 60 * 60 * 24 * 3, + '3d', + ), + ], + [ + CandleDuration.W_1, + new CandleDetails( + 'candleSticksOneWeek', + 'D', + 90, + 90, + 60 * 60 * 24 * 7, + '1w', + ), + ], + [ + CandleDuration.D_30, + new CandleDetails( + 'candleSticksOneMonth', + 'D', + 90, + 90, + 60 * 60 * 24 * 30, + '30d', + ), + ], + ]); + + public static get(candle: CandleDuration): CandleDetails { + return this.candles.get(candle) as CandleDetails; + } +} diff --git a/apps/frontend/src/app/2_molecules/TradingChart/streaming.ts b/apps/frontend/src/app/2_molecules/TradingChart/streaming.ts new file mode 100644 index 000000000..d3c204853 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/streaming.ts @@ -0,0 +1,121 @@ +import { ApolloClient } from '@apollo/client'; + +import { REFRESH_RATE, resolutionMap } from './TradingChart.constants'; +import { SubItem } from './TradingChart.types'; +import { + getTokensFromSymbol, + pushPrice, + queryCandles, +} from './TradingChart.utils'; +import { CandleDuration, TradingCandleDictionary } from './dictionary'; + +export class Streaming { + private client: ApolloClient | null = null; + private subscriptions = new Map(); + + private async onUpdate(subscriptionItem: SubItem) { + if (!subscriptionItem?.symbolInfo?.name) { + return; + } + + const candleDuration: CandleDuration = + resolutionMap[subscriptionItem.resolution]; + + const details = TradingCandleDictionary.get(candleDuration); + + const { baseToken, quoteToken, chainId } = getTokensFromSymbol( + subscriptionItem.symbolInfo.name, + ); + + queryCandles( + chainId, + details, + await baseToken, + await quoteToken, + subscriptionItem?.lastBar?.time / 1e3, + Math.ceil(Date.now() / 1e3), + ) + .then(bars => { + bars.reverse().forEach((item, index) => { + let bar; + if ( + !subscriptionItem.lastBar || + item.time > subscriptionItem?.lastBar?.time + ) { + // generate new bar + bar = { + ...item, + time: item.time, + }; + } else if ( + subscriptionItem.lastBar && + item.time === subscriptionItem?.lastBar?.time + ) { + // update last bar + bar = { + ...subscriptionItem.lastBar, + high: Math.max(subscriptionItem.lastBar.high, item.high), + low: Math.min(subscriptionItem.lastBar.low, item.low), + close: item.close, + }; + pushPrice(`${baseToken}/${quoteToken}`, bar.close); + } else { + // do not update + return; + } + + // update last bar cache and execute chart callback + subscriptionItem.lastBar = bar; + subscriptionItem.handler(bar); + }); + }) + .catch(error => { + console.error('Error in onUpdate', error); + }); + } + + private addSubscription(subItem) { + subItem.timer = setInterval(() => this.onUpdate(subItem), REFRESH_RATE); + this.subscriptions.set(subItem.subscribeUID, subItem); + } + + private clearSubscription(subscribeUID) { + const currentSub = this.subscriptions.get(subscribeUID); + if (!currentSub) return; + clearInterval(currentSub.timer); + delete currentSub.timer; + this.subscriptions.delete(subscribeUID); + } + + public subscribeOnStream( + client: ApolloClient, + symbolInfo, + resolution, + onRealtimeCallback, + subscribeUID, + onResetCacheNeededCallback, + lastBar, + ) { + this.client = client; + + let subscriptionItem = this.subscriptions.get(subscribeUID); + if (subscriptionItem) { + return; + } + + subscriptionItem = { + symbolInfo, + subscribeUID, + resolution, + lastBar, + handler: onRealtimeCallback, + }; + this.addSubscription(subscriptionItem); + } + + public unsubscribeFromStream(subscriberUID) { + this.clearSubscription(subscriberUID); + } +} + +export const stream = new Streaming(); diff --git a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.constants.ts b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.constants.ts index 87e08363c..dc45d154c 100644 --- a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.constants.ts +++ b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.constants.ts @@ -43,13 +43,21 @@ export const MASSET = COMMON_SYMBOLS.DLLR; export const SMART_ROUTER_ALLOWED_TOKENS = [...BASSETS, MASSET]; export const DEFAULT_SWAP_ENTRIES: Partial> = { - [ChainIds.RSK_MAINNET]: COMMON_SYMBOLS.DLLR, - [ChainIds.RSK_TESTNET]: COMMON_SYMBOLS.DLLR, + [ChainIds.RSK_MAINNET]: COMMON_SYMBOLS.BTC, + [ChainIds.RSK_TESTNET]: COMMON_SYMBOLS.BTC, [ChainIds.BOB_MAINNET]: COMMON_SYMBOLS.ETH, [ChainIds.BOB_TESTNET]: COMMON_SYMBOLS.ETH, [ChainIds.SEPOLIA]: COMMON_SYMBOLS.ETH, }; +export const DEFAULT_SWAP_DESTINATIONS: Partial> = { + [ChainIds.RSK_MAINNET]: COMMON_SYMBOLS.SOV, + [ChainIds.RSK_TESTNET]: COMMON_SYMBOLS.SOV, + [ChainIds.BOB_MAINNET]: COMMON_SYMBOLS.SOV, + [ChainIds.BOB_TESTNET]: COMMON_SYMBOLS.SOV, + [ChainIds.SEPOLIA]: COMMON_SYMBOLS.SOV, +}; + export const CATEGORY_TOKENS: Record = { [CategoryType.Stablecoins]: SMART_ROUTER_STABLECOINS, [CategoryType.BTC]: [COMMON_SYMBOLS.BTC], diff --git a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.tsx b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.tsx index f29275cae..fae19d022 100644 --- a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.tsx +++ b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.tsx @@ -6,8 +6,8 @@ import { Helmet } from 'react-helmet-async'; import { useSearchParams } from 'react-router-dom'; import { getAssetData } from '@sovryn/contracts'; -import { ChainId } from '@sovryn/ethers-provider'; import { getProvider } from '@sovryn/ethers-provider'; +import { ChainId } from '@sovryn/onboard-common'; import { SwapRoute } from '@sovryn/sdk'; import { SmartRouter } from '@sovryn/sdk'; import { @@ -36,6 +36,7 @@ import { RSK_CHAIN_ID } from '../../../config/chains'; import { AmountRenderer } from '../../2_molecules/AmountRenderer/AmountRenderer'; import { AssetRenderer } from '../../2_molecules/AssetRenderer/AssetRenderer'; import { MaxButton } from '../../2_molecules/MaxButton/MaxButton'; +import { TradingChart } from '../../2_molecules/TradingChart/TradingChart'; import { TOKEN_RENDER_PRECISION } from '../../../constants/currencies'; import { getTokenDisplayName } from '../../../constants/tokens'; import { useAccount } from '../../../hooks/useAccount'; @@ -52,6 +53,7 @@ import { removeTrailingZerosFromString } from '../../../utils/helpers'; import { decimalic, fromWei } from '../../../utils/math'; import { CATEGORY_TOKENS, + DEFAULT_SWAP_DESTINATIONS, FIXED_MYNT_RATE, FIXED_RATE_ROUTES, } from './ConvertPage.constants'; @@ -188,6 +190,19 @@ const ConvertPage: FC = () => { return DEFAULT_SWAP_ENTRIES[currentChainId] ?? COMMON_SYMBOLS.ETH; }, [currentChainId, fromToken]); + const defaultDestinationToken = useMemo(() => { + if (toToken) { + const item = listAssetsOfChain(currentChainId).find( + item => item.symbol.toLowerCase() === toToken.toLowerCase(), + ); + + if (item) { + return item.symbol; + } + } + return DEFAULT_SWAP_DESTINATIONS[currentChainId] ?? COMMON_SYMBOLS.SOV; + }, [currentChainId, toToken]); + const [sourceToken, setSourceToken] = useState(defaultSourceToken); const handleCategorySelect = useCallback( @@ -278,7 +293,9 @@ const ConvertPage: FC = () => { [hasMyntBalance, tokenOptions], ); - const [destinationToken, setDestinationToken] = useState(''); + const [destinationToken, setDestinationToken] = useState( + defaultDestinationToken, + ); const onTransactionSuccess = useCallback(() => setAmount(''), [setAmount]); @@ -478,6 +495,11 @@ const ConvertPage: FC = () => { return t(commonTranslations.na); }, [price, priceToken]); + const renderPair = useMemo( + () => `${sourceToken}/${destinationToken}/${currentChainId}`, + [sourceToken, destinationToken, currentChainId], + ); + const togglePriceQuote = useCallback( () => setPriceQuote(value => !value), [], @@ -603,165 +625,169 @@ const ConvertPage: FC = () => { {t(pageTranslations.subtitle)} -
-
-
- - {t(pageTranslations.form.convertFrom)} - +
+ + +
+
+
+ + {t(pageTranslations.form.convertFrom)} + + + +
+ +
+ + + handleCategorySelect(category, setSourceCategories) + } + onTokenChange={onSourceTokenChange} + dataAttribute="convert-from-asset" + /> +
+ + {!isValidAmount && ( + + )} +
- +
+
-
- - - handleCategorySelect(category, setSourceCategories) - } - onTokenChange={onSourceTokenChange} - dataAttribute="convert-from-asset" - /> +
+ + {t(pageTranslations.form.convertTo)} + + +
+ + + handleCategorySelect(category, setDestinationCategories) + } + onTokenChange={onDestinationTokenChange} + dataAttribute="convert-to-asset" + /> +
- {!isValidAmount && ( + setShowAdvancedSettings(!showAdvancedSettings)} + dataAttribute="convert-settings" + > +
+ setSlippageTolerance(e.target.value)} + label={t(pageTranslations.slippageTolerance)} + className="max-w-none w-full" + unit="%" + step="0.01" + decimalPrecision={2} + placeholder="0" + max="100" + /> +
+
+ + {sourceToken && destinationToken && quote ? ( + + + } + /> + + + ) : null} + + {hasQuoteError && ( )} -
-
- -
+
diff --git a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.utils.ts b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.utils.ts index b9b5b76ad..3ae1e5833 100644 --- a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.utils.ts +++ b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.utils.ts @@ -6,8 +6,8 @@ import { getContract } from '@sovryn/contracts'; import { getProvider } from '@sovryn/ethers-provider'; import { SmartRouter, SwapRoute } from '@sovryn/sdk'; -import { getRskChainId } from '../../../utils/chain'; import { SWAP_ROUTES } from './ConvertPage.constants'; +import { getCurrentChain } from '../../../hooks/useChainStore'; export const getRouteContract = async ( route: SwapRoute, @@ -26,7 +26,7 @@ export const getRouteContract = async ( const { address, abi } = await getContract( contractName, contractGroup, - getRskChainId(), + getCurrentChain(), ); return new Contract(address, abi, signer); diff --git a/yarn.lock b/yarn.lock index 20c556a0d..763079595 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4708,6 +4708,11 @@ resolved "https://registry.yarnpkg.com/@sovryn-zero/lib-ethers/-/lib-ethers-0.2.5.tgz#a689b6b86686f7e54a6bbfac965a40f49a2fa4ea" integrity sha512-EflaS8gsnlnslo1ZgxFm4c6xBAGPW1dJJ/8ItSgKhYBTYy50zulGRZwln3c4QVt4Sq6dkasix5NsqGXkTQutOA== +"@sovryn/charting-library@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sovryn/charting-library/-/charting-library-2.0.0.tgz#78878af5b3b43d8a2c7c5c952abeef064cbc31fe" + integrity sha512-rjcp+XKBTWuCJFkU9O5B3LfRLSwgT44UzQshX0EhJ8b0PqIqHLXKhALVSJafr8gRXFLu3kPqDckYrAELFeQ66A== + "@sovryn/onboard-bitget@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@sovryn/onboard-bitget/-/onboard-bitget-1.0.1.tgz#aee1c3a1fc714e9232e84b54e7732f2191fd6e7d" @@ -22468,6 +22473,11 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== +storage-factory@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/storage-factory/-/storage-factory-0.2.1.tgz#e484493a888cd2de6ffcb51ad2c95bad0964f950" + integrity sha512-zyhSqhFbK7q1ovq1Tf4WyshYwy8jLkKIUnWBPnakXarvFRljemhz9Zlg0rxwnZiqUdJQ3iox36VLtE/XHqQ4og== + store2@^2.12.0: version "2.14.2" resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068"