diff --git a/src/config/localStorage.ts b/src/config/localStorage.ts index f9390693ae..e9f33601f7 100644 --- a/src/config/localStorage.ts +++ b/src/config/localStorage.ts @@ -125,6 +125,10 @@ export function getSubaccountConfigKey(chainId: number | undefined, account: str return [chainId, account, "one-click-trading-config"]; } +export function getIsLargeAccountKey(account: string) { + return [account, "is-large-account"]; +} + export function getSyntheticsReceiveMoneyTokenKey( chainId: number, marketName: string | undefined, diff --git a/src/context/SyntheticsStateContext/SyntheticsStateContextProvider.tsx b/src/context/SyntheticsStateContext/SyntheticsStateContextProvider.tsx index 92952960f5..5d7804154e 100644 --- a/src/context/SyntheticsStateContext/SyntheticsStateContextProvider.tsx +++ b/src/context/SyntheticsStateContext/SyntheticsStateContextProvider.tsx @@ -2,6 +2,7 @@ import { getKeepLeverageKey } from "config/localStorage"; import { SettingsContextType, useSettings } from "context/SettingsContext/SettingsContextProvider"; import { UserReferralInfo, useUserReferralInfoRequest } from "domain/referrals"; import { PeriodAccountStats, useAccountStats, usePeriodAccountStats } from "domain/synthetics/accountStats"; +import { useIsLargeAccountTracker } from "lib/rpc/bestRpcTracker"; import { useGasLimits, useGasPrice } from "domain/synthetics/fees"; import { RebateInfoItem, useRebatesInfoRequest } from "domain/synthetics/fees/useRebatesInfo"; import useUiFeeFactorRequest from "domain/synthetics/fees/utils/useUiFeeFactor"; @@ -81,6 +82,7 @@ export type SyntheticsState = { lastWeekAccountStats?: PeriodAccountStats; lastMonthAccountStats?: PeriodAccountStats; accountStats?: AccountStats; + isLargeAccount?: boolean; }; claims: { accruedPositionPriceImpactFees: RebateInfoItem[]; @@ -187,6 +189,8 @@ export function SyntheticsStateContextProvider({ const timePerios = useMemo(() => getTimePeriodsInSeconds(), []); + const isLargeAccount = useIsLargeAccountTracker(walletAccount); + const { data: lastWeekAccountStats } = usePeriodAccountStats(chainId, { account, from: timePerios.week[0], @@ -256,6 +260,7 @@ export function SyntheticsStateContextProvider({ lastWeekAccountStats, lastMonthAccountStats, accountStats, + isLargeAccount, }, claims: { accruedPositionPriceImpactFees, claimablePositionPriceImpactFees }, leaderboard, @@ -300,6 +305,7 @@ export function SyntheticsStateContextProvider({ positionSellerState, positionEditorState, confirmationBoxState, + isLargeAccount, ]); latestState = state; diff --git a/src/domain/synthetics/accountStats/index.ts b/src/domain/synthetics/accountStats/index.ts index 72ba019d40..50f1ccbae5 100644 --- a/src/domain/synthetics/accountStats/index.ts +++ b/src/domain/synthetics/accountStats/index.ts @@ -1,3 +1,4 @@ export * from "./useAccountStats"; export * from "./usePeriodAccountStats"; export * from "./usePnlSummaryData"; +export * from "./useIsLargeAccount"; diff --git a/src/domain/synthetics/accountStats/useAccountVolumeStats.ts b/src/domain/synthetics/accountStats/useAccountVolumeStats.ts new file mode 100644 index 0000000000..aab79b6724 --- /dev/null +++ b/src/domain/synthetics/accountStats/useAccountVolumeStats.ts @@ -0,0 +1,95 @@ +import { gql } from "@apollo/client"; +import { getSubsquidGraphClient } from "lib/subgraph"; +import { useMemo } from "react"; +import useSWR from "swr"; +import { subDays, format, eachDayOfInterval } from "date-fns"; +import { toUtcDayStart } from "lib/dates"; +import { CONFIG_UPDATE_INTERVAL } from "lib/timeConstants"; +import { ARBITRUM, AVALANCHE } from "config/chains"; + +const LARGE_ACCOUNT_CHAINS = [ARBITRUM, AVALANCHE]; + +export function useAccountVolumeStats(params: { account?: string }) { + const { account } = params; + + const now = new Date(); + const date30dAgo = subDays(now, 30); + const last30dList = eachDayOfInterval({ + start: date30dAgo, + end: now, + }); + + const { data, error, isLoading } = useSWR<{ + totalVolume: bigint; + dailyVolume: { date: string; volume: bigint }[]; + }>(account ? ["useAccountVolumeStats", account] : null, { + fetcher: async () => { + const chainPromises = LARGE_ACCOUNT_CHAINS.map(async (chainId) => { + const client = getSubsquidGraphClient(chainId); + + const dailyQueries = last30dList.map((day, index) => { + const from = Math.floor(toUtcDayStart(day)); + const to = Math.floor(toUtcDayStart(day) + 24 * 60 * 60); + + return ` + day${index}: periodAccountStats( + where: { id_eq: "${account}", from: ${from}, to: ${to} } + ) { + volume + } + `; + }); + + const query = gql` + query AccountVolumeStats { + total: accountStats(where: { id_eq: "${account}" }) { + volume + } + ${dailyQueries.join("\n")} + } + `; + + const res = await client?.query({ + query, + fetchPolicy: "no-cache", + }); + + const totalVolume = BigInt(res?.data?.total?.[0]?.volume ?? 0); + + const dailyVolume = last30dList.map((day, index) => { + const volume = BigInt(res?.data[`day${index}`]?.[0]?.volume ?? 0); + return { + date: format(day, "yyyy-MM-dd"), + volume, + }; + }); + + return { + totalVolume, + dailyVolume, + }; + }); + + const chainResults = await Promise.all(chainPromises); + + const totalVolume = chainResults.reduce((acc, result) => acc + result.totalVolume, 0n); + + const dailyVolume = last30dList.map((day, index) => { + const volume = chainResults.reduce((acc, { dailyVolume }) => acc + dailyVolume[index].volume, 0n); + + return { + date: format(day, "yyyy-MM-dd"), + volume, + }; + }); + + return { + totalVolume, + dailyVolume, + }; + }, + refreshInterval: CONFIG_UPDATE_INTERVAL, + }); + + return useMemo(() => ({ data, error, isLoading }), [data, error, isLoading]); +} diff --git a/src/domain/synthetics/accountStats/useIsLargeAccount.ts b/src/domain/synthetics/accountStats/useIsLargeAccount.ts new file mode 100644 index 0000000000..3d6da93922 --- /dev/null +++ b/src/domain/synthetics/accountStats/useIsLargeAccount.ts @@ -0,0 +1,30 @@ +import { useMemo } from "react"; +import { USD_DECIMALS } from "config/factors"; +import { expandDecimals } from "lib/numbers"; + +import { useAccountVolumeStats } from "./useAccountVolumeStats"; + +// Thresholds to recognise large accounts +const MAX_DAILY_VOLUME = expandDecimals(220_000n, USD_DECIMALS); +const AGG_14_DAYS_VOLUME = expandDecimals(1_200_000n, USD_DECIMALS); +const AGG_ALL_TIME_VOLUME = expandDecimals(3_500_000n, USD_DECIMALS); + +export function useIsLargeAccount(account?: string) { + const { data, error, isLoading } = useAccountVolumeStats({ account }); + + const isLargeAccount = useMemo(() => { + if (!data || isLoading || error) return undefined; + + const { totalVolume, dailyVolume } = data; + + const maxDailyVolume = dailyVolume.reduce((max, day) => (day.volume > max ? day.volume : max), 0n); + + const last14DaysVolume = dailyVolume.slice(-14).reduce((acc, day) => acc + day.volume, 0n); + + return ( + maxDailyVolume >= MAX_DAILY_VOLUME || last14DaysVolume >= AGG_14_DAYS_VOLUME || totalVolume >= AGG_ALL_TIME_VOLUME + ); + }, [data, isLoading, error]); + + return isLargeAccount; +} diff --git a/src/lib/metrics/types.ts b/src/lib/metrics/types.ts index 681ba25496..da764c7ef2 100644 --- a/src/lib/metrics/types.ts +++ b/src/lib/metrics/types.ts @@ -149,6 +149,7 @@ export type MulticallTimeoutEvent = { isInMainThread: boolean; requestType?: "initial" | "retry"; rpcProvider?: string; + isLargeAccount?: boolean; errorMessage: string; }; }; @@ -160,6 +161,7 @@ export type MulticallErrorEvent = { isInMainThread: boolean; rpcProvider?: string; requestType?: "initial" | "retry"; + isLargeAccount?: boolean; errorMessage: string; }; }; diff --git a/src/lib/multicall/Multicall.ts b/src/lib/multicall/Multicall.ts index 402d1c1571..a0bba5ff22 100644 --- a/src/lib/multicall/Multicall.ts +++ b/src/lib/multicall/Multicall.ts @@ -147,7 +147,12 @@ export class Multicall { private abFlags: Record ) {} - async call(providerUrls: MulticallProviderUrls, request: MulticallRequestConfig, maxTimeout: number) { + async call( + providerUrls: MulticallProviderUrls, + request: MulticallRequestConfig, + maxTimeout: number, + isLargeAccount: boolean + ) { const originalKeys: { contractKey: string; callKey: string; @@ -208,6 +213,7 @@ export class Multicall { isInMainThread: !isWebWorker, requestType, rpcProvider, + isLargeAccount, }, }); }; @@ -227,6 +233,7 @@ export class Multicall { data: { requestType, rpcProvider, + isLargeAccount, }, }); }; @@ -332,6 +339,7 @@ export class Multicall { rpcProvider: fallbackProviderName, isInMainThread: !isWebWorker, errorMessage: _viemError.message, + isLargeAccount, }, }); @@ -375,6 +383,7 @@ export class Multicall { isInMainThread: !isWebWorker, requestType: "initial", rpcProvider: rpcProviderName, + isLargeAccount, errorMessage: _viemError.message.slice(0, 150), }, }); @@ -398,6 +407,7 @@ export class Multicall { requestType: "initial", rpcProvider: rpcProviderName, isInMainThread: !isWebWorker, + isLargeAccount, errorMessage: serializeMulticallErrors(result.errors), }, }); diff --git a/src/lib/multicall/executeMulticallMainThread.ts b/src/lib/multicall/executeMulticallMainThread.ts index 56cc3b0db5..89f75cadfd 100644 --- a/src/lib/multicall/executeMulticallMainThread.ts +++ b/src/lib/multicall/executeMulticallMainThread.ts @@ -1,7 +1,7 @@ import { MAX_TIMEOUT, Multicall } from "./Multicall"; import type { MulticallRequestConfig } from "./types"; import { getAbFlags } from "config/ab"; -import { getBestRpcUrl } from "lib/rpc/bestRpcTracker"; +import { getBestRpcUrl, getIsLargeAccount } from "lib/rpc/bestRpcTracker"; import { getFallbackRpcUrl } from "config/chains"; export async function executeMulticallMainThread(chainId: number, request: MulticallRequestConfig) { @@ -10,6 +10,7 @@ export async function executeMulticallMainThread(chainId: number, request: Multi primary: getBestRpcUrl(chainId), secondary: getFallbackRpcUrl(chainId), }; + const isLargeAccount = getIsLargeAccount(); - return multicall?.call(providerUrls, request, MAX_TIMEOUT); + return multicall?.call(providerUrls, request, MAX_TIMEOUT, isLargeAccount); } diff --git a/src/lib/multicall/executeMulticallWorker.ts b/src/lib/multicall/executeMulticallWorker.ts index 1307d66f13..d56a34926e 100644 --- a/src/lib/multicall/executeMulticallWorker.ts +++ b/src/lib/multicall/executeMulticallWorker.ts @@ -10,7 +10,7 @@ import { executeMulticallMainThread } from "./executeMulticallMainThread"; import type { MulticallRequestConfig, MulticallResult } from "./types"; import { MetricEventParams, MulticallTimeoutEvent } from "lib/metrics"; import { getAbFlags } from "config/ab"; -import { getBestRpcUrl } from "lib/rpc/bestRpcTracker"; +import { getBestRpcUrl, getIsLargeAccount } from "lib/rpc/bestRpcTracker"; import { getFallbackRpcUrl } from "config/chains"; const executorWorker: Worker = new Worker(new URL("./multicall.worker", import.meta.url), { type: "module" }); @@ -86,6 +86,7 @@ export async function executeMulticallWorker( providerUrls, request, abFlags: getAbFlags(), + isLargeAccount: getIsLargeAccount(), PRODUCTION_PREVIEW_KEY: localStorage.getItem(PRODUCTION_PREVIEW_KEY), }); diff --git a/src/lib/multicall/multicall.worker.ts b/src/lib/multicall/multicall.worker.ts index 7ac626294d..3248370019 100644 --- a/src/lib/multicall/multicall.worker.ts +++ b/src/lib/multicall/multicall.worker.ts @@ -12,22 +12,23 @@ async function executeMulticall( chainId: number, providerUrls: MulticallProviderUrls, request: MulticallRequestConfig, - abFlags: Record + abFlags: Record, + isLargeAccount: boolean ) { const multicall = await Multicall.getInstance(chainId, abFlags); - return multicall?.call(providerUrls, request, MAX_TIMEOUT); + return multicall?.call(providerUrls, request, MAX_TIMEOUT, isLargeAccount); } self.addEventListener("message", run); async function run(event) { - const { PRODUCTION_PREVIEW_KEY, chainId, providerUrls, request, id, abFlags } = event.data; + const { PRODUCTION_PREVIEW_KEY, chainId, providerUrls, request, id, abFlags, isLargeAccount } = event.data; // @ts-ignore self.PRODUCTION_PREVIEW_KEY = PRODUCTION_PREVIEW_KEY; try { - const result = await executeMulticall(chainId, providerUrls, request, abFlags); + const result = await executeMulticall(chainId, providerUrls, request, abFlags, isLargeAccount); postMessage({ id, diff --git a/src/lib/rpc/bestRpcTracker.ts b/src/lib/rpc/bestRpcTracker.ts index 8912c9c6d6..ec8b179db1 100644 --- a/src/lib/rpc/bestRpcTracker.ts +++ b/src/lib/rpc/bestRpcTracker.ts @@ -1,13 +1,14 @@ import { Provider, ethers } from "ethers"; import { RPC_PROVIDERS, + FALLBACK_PROVIDERS, SUPPORTED_CHAIN_IDS, ARBITRUM, AVALANCHE, AVALANCHE_FUJI, getFallbackRpcUrl, } from "config/chains"; -import { getRpcProviderKey } from "config/localStorage"; +import { getRpcProviderKey, getIsLargeAccountKey } from "config/localStorage"; import { isDebugMode } from "lib/localStorage"; import orderBy from "lodash/orderBy"; import minBy from "lodash/minBy"; @@ -20,9 +21,12 @@ import { getIsFlagEnabled } from "config/ab"; import { sleep } from "lib/sleep"; import sample from "lodash/sample"; import { useEffect, useState } from "react"; +import { useLocalStorageSerializeKey } from "lib/localStorage"; +import { zeroAddress } from "viem"; import { getProviderNameFromUrl } from "lib/rpc/getProviderNameFromUrl"; import { emitMetricCounter } from "lib/metrics/emitMetricEvent"; +import { useIsLargeAccount } from "domain/synthetics/accountStats/useIsLargeAccount"; const PROBE_TIMEOUT = 10 * 1000; // 10 seconds / Frequency of RPC probing const PROBE_FAIL_TIMEOUT = 10 * 1000; // 10 seconds / Abort RPC probe if it takes longer @@ -49,11 +53,13 @@ type ProbeData = { responseTime: number | null; blockNumber: number | null; timestamp: Date; + isPublic: boolean; }; type ProviderData = { url: string; provider: Provider; + isPublic: boolean; }; type RpcTrackerState = { @@ -67,8 +73,10 @@ type RpcTrackerState = { }; }; -const trackerState = initTrackerState(); let trackerTimeoutId: number | null = null; +let isLargeAccount = false; + +const trackerState = initTrackerState(); trackRpcProviders({ warmUp: true }); @@ -111,8 +119,10 @@ function trackRpcProviders({ warmUp = false } = {}) { async function getBestRpcProviderForChain({ providers, chainId }: RpcTrackerState[number]) { const providersList = Object.values(providers); - const probePromises = providersList.map((providerInfo) => { - return probeRpc(chainId, providerInfo.provider, providerInfo.url); + const providersToProbe = isLargeAccount ? providersList : providersList.filter(({ isPublic }) => isPublic); + + const probePromises = providersToProbe.map((providerInfo) => { + return probeRpc(chainId, providerInfo.provider, providerInfo.url, providerInfo.isPublic); }); const probeResults = await Promise.all(probePromises); @@ -158,16 +168,31 @@ async function getBestRpcProviderForChain({ providers, chainId }: RpcTrackerStat const bestResponseTimeValidProbe = minBy(validProbesStats, "responseTime"); const bestBlockNumberValidProbe = maxBy(validProbesStats, "blockNumber"); + if (!bestResponseTimeValidProbe?.url) { + throw new Error("no-success-probes"); + } + + let nextBestRpc = bestResponseTimeValidProbe; + + if (isLargeAccount) { + const privateRpcResult = validProbesStats.find((probe) => !probe.isPublic); + + if (privateRpcResult) { + nextBestRpc = privateRpcResult; + } + } + if (isDebugMode()) { // eslint-disable-next-line no-console console.table( orderBy( probeStats.map((probe) => ({ url: probe.url, - isSelected: probe.url === bestResponseTimeValidProbe?.url ? "✅" : "", + isSelected: probe.url === nextBestRpc.url ? "✅" : "", isValid: probe.isValid ? "✅" : "❌", responseTime: probe.responseTime, blockNumber: probe.blockNumber, + isPublic: probe.isPublic ? "yes" : "no", })), ["responseTime"], ["asc"] @@ -175,17 +200,13 @@ async function getBestRpcProviderForChain({ providers, chainId }: RpcTrackerStat ); } - if (!bestResponseTimeValidProbe?.url) { - throw new Error("no-success-probes"); - } - const bestBlockGap = - bestBlockNumberValidProbe?.blockNumber && bestResponseTimeValidProbe.blockNumber - ? bestBlockNumberValidProbe.blockNumber - bestResponseTimeValidProbe.blockNumber + bestBlockNumberValidProbe?.blockNumber && nextBestRpc.blockNumber + ? bestBlockNumberValidProbe.blockNumber - nextBestRpc.blockNumber : undefined; return { - url: bestResponseTimeValidProbe.url, + url: nextBestRpc.url, bestBlockGap, }; } @@ -200,6 +221,7 @@ function setCurrentProvider(chainId: number, newProviderUrl: string, bestBlockGa data: { rpcProvider: getProviderNameFromUrl(newProviderUrl), bestBlockGap: bestBlockGap ?? "unknown", + isLargeAccount, }, }); @@ -214,7 +236,12 @@ function setCurrentProvider(chainId: number, newProviderUrl: string, bestBlockGa ); } -async function probeRpc(chainId: number, provider: Provider, providerUrl: string): Promise { +async function probeRpc( + chainId: number, + provider: Provider, + providerUrl: string, + isPublic: boolean +): Promise { const controller = new AbortController(); let responseTime: number | null = null; @@ -300,6 +327,7 @@ async function probeRpc(chainId: number, provider: Provider, providerUrl: string blockNumber, timestamp: new Date(), isSuccess, + isPublic, }; })(), ]).catch(() => { @@ -309,6 +337,7 @@ async function probeRpc(chainId: number, provider: Provider, providerUrl: string blockNumber: null, timestamp: new Date(), isSuccess: false, + isPublic, }; }); } @@ -317,17 +346,24 @@ function initTrackerState() { const now = Date.now(); return SUPPORTED_CHAIN_IDS.reduce((acc, chainId) => { - const providersList = RPC_PROVIDERS[chainId] as string[]; - const providers = providersList.reduce>((acc, rpcUrl) => { - acc[rpcUrl] = { - url: rpcUrl, - provider: new ethers.JsonRpcProvider(rpcUrl), - }; + const prepareProviders = (urls: string[], { isPublic }: { isPublic: boolean }) => { + return urls.reduce>((acc, rpcUrl) => { + acc[rpcUrl] = { + url: rpcUrl, + provider: new ethers.JsonRpcProvider(rpcUrl), + isPublic, + }; + + return acc; + }, {}); + }; - return acc; - }, {}); + const providers = { + ...prepareProviders(RPC_PROVIDERS[chainId], { isPublic: true }), + ...prepareProviders(FALLBACK_PROVIDERS[chainId], { isPublic: false }), + }; - let currentBestProviderUrl: string = RPC_PROVIDERS[chainId][0]; + let currentBestProviderUrl: string = isLargeAccount ? FALLBACK_PROVIDERS[chainId][0] : RPC_PROVIDERS[chainId][0]; const storageKey = JSON.stringify(getRpcProviderKey(chainId)); const storedProviderData = localStorage.getItem(storageKey); @@ -368,6 +404,10 @@ export function getBestRpcUrl(chainId: number) { } if (!trackerState[chainId]) { + if (isLargeAccount) { + return getFallbackRpcUrl(chainId); + } + if (RPC_PROVIDERS[chainId]?.length) { return sample(RPC_PROVIDERS[chainId]); } @@ -405,3 +445,30 @@ export function useBestRpcUrl(chainId: number) { return bestRpcUrl; } + +export function useIsLargeAccountTracker(account?: string) { + const isLargeCurrentAccount = useIsLargeAccount(account); + const [isLargeAccountStoredValue, setIsLargeAccountStoredValue] = useLocalStorageSerializeKey( + getIsLargeAccountKey(account ?? zeroAddress), + false + ); + + useEffect(() => { + if (!account) { + isLargeAccount = false; + } else if (isLargeCurrentAccount !== undefined) { + setIsLargeAccountStoredValue(isLargeCurrentAccount); + isLargeAccount = isLargeCurrentAccount; + } else if (isLargeAccountStoredValue) { + isLargeAccount = true; + } else { + isLargeAccount = false; + } + }, [account, isLargeCurrentAccount, isLargeAccountStoredValue, setIsLargeAccountStoredValue]); + + return isLargeAccount; +} + +export function getIsLargeAccount() { + return isLargeAccount; +}