From c6edc67ef07b18356af902daba17f8e18050acb5 Mon Sep 17 00:00:00 2001 From: Christopher Howard Date: Thu, 29 Aug 2024 15:36:07 -0400 Subject: [PATCH] Implement perceived finality (#6037) * feat: perceived finality initial * fix: update chain id import --- src/hooks/useWatchPendingTxs.ts | 40 ++++++ src/resources/assets/UserAssetsQuery.ts | 47 +++++-- src/state/staleBalances/index.test.ts | 165 ++++++++++++++++++++++++ src/state/staleBalances/index.ts | 99 ++++++++++++++ 4 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 src/state/staleBalances/index.test.ts create mode 100644 src/state/staleBalances/index.ts diff --git a/src/hooks/useWatchPendingTxs.ts b/src/hooks/useWatchPendingTxs.ts index 8e22b715a99..72ec35f0a93 100644 --- a/src/hooks/useWatchPendingTxs.ts +++ b/src/hooks/useWatchPendingTxs.ts @@ -16,6 +16,7 @@ import { Address } from 'viem'; import { nftsQueryKey } from '@/resources/nfts'; import { getNftSortForAddress } from './useNFTsSortBy'; import { ChainId } from '@/networks/types'; +import { staleBalancesStore } from '@/state/staleBalances'; export const useWatchPendingTransactions = ({ address }: { address: string }) => { const { storePendingTransactions, setPendingTransactions } = usePendingTransactionsStore(state => ({ @@ -164,6 +165,7 @@ export const useWatchPendingTransactions = ({ address }: { address: string }) => ); const watchPendingTransactions = useCallback(async () => { + const connectedToHardhat = getIsHardhatConnected(); if (!pendingTransactions?.length) return; const updatedPendingTransactions = await Promise.all( pendingTransactions.map((tx: RainbowTransaction) => processPendingTransaction(tx)) @@ -190,6 +192,20 @@ export const useWatchPendingTransactions = ({ address }: { address: string }) => const chainIds = RainbowNetworkObjects.filter(networkObject => networkObject.enabled && networkObject.networkType !== 'testnet').map( networkObject => networkObject.id ); + minedTransactions.forEach(tx => { + if (tx.changes?.length) { + tx.changes?.forEach(change => { + processStaleAsset({ asset: change?.asset, address, transactionHash: tx?.hash }); + }); + } else if (tx.asset) { + processStaleAsset({ address, asset: tx.asset, transactionHash: tx?.hash }); + } + }); + + queryClient.refetchQueries({ + queryKey: userAssetsQueryKey({ address, currency: nativeCurrency, connectedToHardhat }), + }); + await queryClient.refetchQueries({ queryKey: consolidatedTransactionsQueryKey({ address, @@ -217,3 +233,27 @@ export const useWatchPendingTransactions = ({ address }: { address: string }) => return { watchPendingTransactions }; }; + +function processStaleAsset({ + asset, + address, + transactionHash, +}: { + asset: RainbowTransaction['asset']; + address: string; + transactionHash: string; +}) { + const { addStaleBalance } = staleBalancesStore.getState(); + const chainId = asset?.chainId; + if (asset && typeof chainId === 'number') { + const changedAssetAddress = asset?.address as Address; + addStaleBalance({ + address, + chainId, + info: { + address: changedAssetAddress, + transactionHash, + }, + }); + } +} diff --git a/src/resources/assets/UserAssetsQuery.ts b/src/resources/assets/UserAssetsQuery.ts index 1e0a285e3d5..47db136d5e2 100644 --- a/src/resources/assets/UserAssetsQuery.ts +++ b/src/resources/assets/UserAssetsQuery.ts @@ -11,6 +11,7 @@ import { filterPositionsData, parseAddressAsset } from './assets'; import { fetchHardhatBalances } from './hardhatAssets'; import { AddysAccountAssetsMeta, AddysAccountAssetsResponse, RainbowAddressAssets } from './types'; import { Network } from '@/networks/types'; +import { staleBalancesStore } from '@/state/staleBalances'; // /////////////////////////////////////////////// // Query Types @@ -32,15 +33,26 @@ type UserAssetsQueryKey = ReturnType; // /////////////////////////////////////////////// // Query Function -const fetchUserAssetsForChainIds = async (address: string, currency: NativeCurrencyKey, chainIds: number[]) => { +const fetchUserAssetsForChainIds = async ({ + address, + currency, + chainIds, + staleBalanceParam, +}: { + address: string; + currency: NativeCurrencyKey; + chainIds: number[]; + staleBalanceParam?: string; +}) => { const chainIdsString = chainIds.join(','); - const url = `https://addys.p.rainbow.me/v3/${chainIdsString}/${address}/assets`; + let url = `https://addys.p.rainbow.me/v3/${chainIdsString}/${address}/assets?currency=${currency.toLowerCase()}`; + + if (staleBalanceParam) { + url += url + staleBalanceParam; + } const response = await rainbowFetch(url, { method: 'get', - params: { - currency: currency.toLowerCase(), - }, headers: { Authorization: `Bearer ${ADDYS_API_KEY}`, }, @@ -66,7 +78,10 @@ async function userAssetsQueryFunction({ network => network.id ); - const { erroredChainIds, results } = await fetchAndParseUserAssetsForChainIds(address, currency, chainIds); + staleBalancesStore.getState().clearExpiredData(address); + const staleBalanceParam = staleBalancesStore.getState().getStaleBalancesQueryParam(address); + + const { erroredChainIds, results } = await fetchAndParseUserAssetsForChainIds({ address, currency, chainIds, staleBalanceParam }); let parsedSuccessResults = results; // grab cached data for chain IDs with errors @@ -102,7 +117,7 @@ const retryErroredChainIds = async ( connectedToHardhat: boolean, erroredChainIds: number[] ) => { - const { meta, results } = await fetchAndParseUserAssetsForChainIds(address, currency, erroredChainIds); + const { meta, results } = await fetchAndParseUserAssetsForChainIds({ address, currency, chainIds: erroredChainIds }); let parsedSuccessResults = results; const successChainIds = meta?.chain_ids; @@ -142,12 +157,18 @@ interface AssetsAndMetadata { results: RainbowAddressAssets; } -const fetchAndParseUserAssetsForChainIds = async ( - address: string, - currency: NativeCurrencyKey, - chainIds: number[] -): Promise => { - const data = await fetchUserAssetsForChainIds(address, currency, chainIds); +const fetchAndParseUserAssetsForChainIds = async ({ + address, + currency, + chainIds, + staleBalanceParam, +}: { + address: string; + currency: NativeCurrencyKey; + chainIds: number[]; + staleBalanceParam?: string; +}): Promise => { + const data = await fetchUserAssetsForChainIds({ address, currency, chainIds, staleBalanceParam }); let parsedSuccessResults = parseUserAssetsByChain(data); // filter out positions data diff --git a/src/state/staleBalances/index.test.ts b/src/state/staleBalances/index.test.ts new file mode 100644 index 00000000000..b07e73acb7b --- /dev/null +++ b/src/state/staleBalances/index.test.ts @@ -0,0 +1,165 @@ +import { Address } from 'viem'; + +import { staleBalancesStore } from '.'; +import { DAI_ADDRESS, OP_ADDRESS } from '@/references'; +import { ETH_ADDRESS } from '@rainbow-me/swaps'; +import { ChainId } from '@/networks/types'; + +const TEST_ADDRESS_1 = '0xFOO'; +const TEST_ADDRESS_2 = '0xBAR'; +const THEN = Date.now() - 700000; +const WHEN = Date.now() + 60000; + +test('should be able to add asset information to the staleBalances object', async () => { + const { addStaleBalance, staleBalances } = staleBalancesStore.getState(); + expect(staleBalances).toStrictEqual({}); + addStaleBalance({ + address: TEST_ADDRESS_1, + chainId: ChainId.mainnet, + info: { + address: DAI_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: THEN, + }, + }); + addStaleBalance({ + address: TEST_ADDRESS_1, + chainId: ChainId.mainnet, + info: { + address: ETH_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: WHEN, + }, + }); + const newStaleBalances = staleBalancesStore.getState().staleBalances; + expect(newStaleBalances).toStrictEqual({ + [TEST_ADDRESS_1]: { + [ChainId.mainnet]: { + [DAI_ADDRESS]: { + address: DAI_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: THEN, + }, + [ETH_ADDRESS]: { + address: ETH_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: WHEN, + }, + }, + }, + }); +}); + +test('should generate accurate stale balance query params and clear expired data - case #1', async () => { + const { getStaleBalancesQueryParam, clearExpiredData } = staleBalancesStore.getState(); + clearExpiredData(TEST_ADDRESS_1); + const queryParam = getStaleBalancesQueryParam(TEST_ADDRESS_1); + expect(queryParam).toStrictEqual(`&token=${ChainId.mainnet}.${ETH_ADDRESS}`); +}); + +test('should be able to remove expired stale balance and preserve unexpired data', async () => { + const { addStaleBalance, clearExpiredData } = staleBalancesStore.getState(); + addStaleBalance({ + address: TEST_ADDRESS_1, + chainId: ChainId.mainnet, + info: { + address: DAI_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: THEN, + }, + }); + addStaleBalance({ + address: TEST_ADDRESS_1, + chainId: ChainId.mainnet, + info: { + address: ETH_ADDRESS as Address, + transactionHash: '0xFOOBAR', + expirationTime: WHEN, + }, + }); + clearExpiredData(TEST_ADDRESS_1); + const newStaleBalances = staleBalancesStore.getState().staleBalances; + expect(newStaleBalances).toStrictEqual({ + [TEST_ADDRESS_1]: { + [ChainId.mainnet]: { + [ETH_ADDRESS]: { + address: ETH_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: WHEN, + }, + }, + }, + }); +}); + +test('should preserve data from other addresses when clearing expired data', async () => { + const { addStaleBalance, clearExpiredData } = staleBalancesStore.getState(); + addStaleBalance({ + address: TEST_ADDRESS_1, + chainId: ChainId.mainnet, + info: { + address: DAI_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: THEN, + }, + }); + addStaleBalance({ + address: TEST_ADDRESS_2, + chainId: ChainId.mainnet, + info: { + address: ETH_ADDRESS as Address, + transactionHash: '0xFOOBAR', + expirationTime: WHEN, + }, + }); + clearExpiredData(TEST_ADDRESS_1); + const newStaleBalances = staleBalancesStore.getState().staleBalances; + expect(newStaleBalances).toStrictEqual({ + [TEST_ADDRESS_1]: { + [ChainId.mainnet]: { + [ETH_ADDRESS]: { + address: ETH_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: WHEN, + }, + }, + }, + [TEST_ADDRESS_2]: { + [ChainId.mainnet]: { + [ETH_ADDRESS]: { + address: ETH_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: WHEN, + }, + }, + }, + }); +}); + +test('should generate accurate stale balance query params and clear expired data - case #2', async () => { + const { getStaleBalancesQueryParam, clearExpiredData } = staleBalancesStore.getState(); + clearExpiredData(TEST_ADDRESS_2); + const queryParam = getStaleBalancesQueryParam(TEST_ADDRESS_2); + expect(queryParam).toStrictEqual(`&token=${ChainId.mainnet}.${ETH_ADDRESS}`); +}); + +test('should generate accurate stale balance query params and clear expired data - case #3', async () => { + const { addStaleBalance, getStaleBalancesQueryParam, clearExpiredData } = staleBalancesStore.getState(); + addStaleBalance({ + address: TEST_ADDRESS_1, + chainId: ChainId.optimism, + info: { + address: OP_ADDRESS, + transactionHash: '0xFOOBAR', + expirationTime: WHEN, + }, + }); + + clearExpiredData(TEST_ADDRESS_1); + const queryParam = getStaleBalancesQueryParam(TEST_ADDRESS_1); + expect(queryParam).toStrictEqual(`&token=${ChainId.mainnet}.${ETH_ADDRESS}&token=${ChainId.optimism}.${OP_ADDRESS}`); + + clearExpiredData(TEST_ADDRESS_2); + const queryParam2 = getStaleBalancesQueryParam(TEST_ADDRESS_2); + expect(queryParam2).toStrictEqual(`&token=${ChainId.mainnet}.${ETH_ADDRESS}`); +}); diff --git a/src/state/staleBalances/index.ts b/src/state/staleBalances/index.ts new file mode 100644 index 00000000000..8a9928aaacb --- /dev/null +++ b/src/state/staleBalances/index.ts @@ -0,0 +1,99 @@ +import { createRainbowStore } from '../internal/createRainbowStore'; + +const TIME_TO_WATCH = 600000; + +interface StaleBalanceInfo { + address: string; + expirationTime?: number; + transactionHash: string; +} + +interface StaleBalances { + [key: string]: StaleBalanceInfo; +} +interface StaleBalancesByChainId { + [key: number]: StaleBalances; +} + +export interface StaleBalancesState { + addStaleBalance: ({ address, chainId, info }: { address: string; chainId: number; info: StaleBalanceInfo }) => void; + clearExpiredData: (address: string) => void; + getStaleBalancesQueryParam: (address: string) => string; + staleBalances: Record; +} + +export const staleBalancesStore = createRainbowStore( + (set, get) => ({ + addStaleBalance: ({ address, chainId, info }: { address: string; chainId: number; info: StaleBalanceInfo }) => { + set(state => { + const { staleBalances } = state; + const staleBalancesForUser = staleBalances[address] || {}; + const staleBalancesForChain = staleBalancesForUser[chainId] || {}; + const newStaleBalancesForChain = { + ...staleBalancesForChain, + [info.address]: { + ...info, + expirationTime: info.expirationTime || Date.now() + TIME_TO_WATCH, + }, + }; + const newStaleBalancesForUser = { + ...staleBalancesForUser, + [chainId]: newStaleBalancesForChain, + }; + return { + staleBalances: { + ...staleBalances, + [address]: newStaleBalancesForUser, + }, + }; + }); + }, + clearExpiredData: (address: string) => { + set(state => { + const { staleBalances } = state; + const staleBalancesForUser = staleBalances[address] || {}; + const newStaleBalancesForUser: StaleBalancesByChainId = { + ...staleBalancesForUser, + }; + for (const c of Object.keys(staleBalancesForUser)) { + const chainId = parseInt(c, 10); + const newStaleBalancesForChain = { + ...(staleBalancesForUser[chainId] || {}), + }; + for (const staleBalance of Object.values(newStaleBalancesForChain)) { + if (typeof staleBalance.expirationTime === 'number' && staleBalance.expirationTime <= Date.now()) { + delete newStaleBalancesForChain[staleBalance.address]; + } + } + newStaleBalancesForUser[chainId] = newStaleBalancesForChain; + } + return { + staleBalances: { + ...staleBalances, + [address]: newStaleBalancesForUser, + }, + }; + }); + }, + getStaleBalancesQueryParam: (address: string) => { + let queryStringFragment = ''; + const { staleBalances } = get(); + const staleBalancesForUser = staleBalances[address]; + for (const c of Object.keys(staleBalancesForUser)) { + const chainId = parseInt(c, 10); + const staleBalancesForChain = staleBalancesForUser[chainId]; + for (const staleBalance of Object.values(staleBalancesForChain)) { + if (typeof staleBalance.expirationTime === 'number') { + queryStringFragment += `&token=${chainId}.${staleBalance.address}`; + } + } + } + return queryStringFragment; + }, + staleBalances: {}, + }), + { + storageKey: 'staleBalances', + version: 0, + } +);