diff --git a/src/components/settings/EnvironmentVariables/index.test.tsx b/src/components/settings/EnvironmentVariables/index.test.tsx new file mode 100644 index 00000000..eaa84d95 --- /dev/null +++ b/src/components/settings/EnvironmentVariables/index.test.tsx @@ -0,0 +1,98 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as chainIdModule from '@/hooks/useChainId' +import * as chainsModule from '@/hooks/useChains' +import { useAppDispatch, useAppSelector } from '@/store' +import { + initialState as initialSettingsState, + setHistoricalRpcLogBatchSize, + setHistoricalRpcLogMaxConcurrentRequests, +} from '@/store/settingsSlice' +import EnvironmentVariables from '.' + +jest.mock('@/store', () => ({ + useAppDispatch: jest.fn(), + useAppSelector: jest.fn(), +})) + +describe('EnvironmentVariables', () => { + const dispatch = jest.fn() + + beforeEach(() => { + localStorage.clear() + dispatch.mockReset() + + jest.spyOn(chainIdModule, 'default').mockReturnValue('1') + jest.spyOn(chainsModule, 'useCurrentChain').mockReturnValue({ + chainId: '1', + publicRpcUri: { value: 'https://rpc.example' }, + } as any) + ;(useAppDispatch as jest.Mock).mockReturnValue(dispatch) + ;(useAppSelector as jest.Mock).mockImplementation((selector: (state: any) => unknown) => + selector({ + settings: initialSettingsState, + }), + ) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('renders Chain queries fields from persisted settings', async () => { + ;(useAppSelector as jest.Mock).mockImplementation((selector: (state: any) => unknown) => + selector({ + settings: { + ...initialSettingsState, + env: { + ...initialSettingsState.env, + rpc: { '1': 'https://rpc.example' }, + historicalRpcLogBatchSize: 2500, + historicalRpcLogMaxConcurrentRequests: 6, + }, + }, + }), + ) + + await act(async () => { + render() + }) + + expect(screen.getByLabelText('Historical log block batch size')).toHaveValue(2500) + expect(screen.getByLabelText('Max parallel historical log requests')).toHaveValue(6) + }) + + it('dispatches both Chain queries values on submit', async () => { + const originalLocation = window.location + + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + reload: jest.fn(), + }, + }) + + await act(async () => { + render() + }) + + await act(async () => { + fireEvent.change(screen.getByLabelText('Historical log block batch size'), { target: { value: '4000' } }) + fireEvent.change(screen.getByLabelText('Max parallel historical log requests'), { target: { value: '8' } }) + }) + + await waitFor(() => expect(screen.getByRole('button', { name: /save/i })).toBeEnabled()) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /save/i })) + }) + + await waitFor(() => expect(dispatch).toHaveBeenCalledWith(setHistoricalRpcLogBatchSize(4000))) + await waitFor(() => expect(dispatch).toHaveBeenCalledWith(setHistoricalRpcLogMaxConcurrentRequests(8))) + + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }) + }) +}) diff --git a/src/components/settings/EnvironmentVariables/index.tsx b/src/components/settings/EnvironmentVariables/index.tsx index ea800bd3..5db76bb3 100644 --- a/src/components/settings/EnvironmentVariables/index.tsx +++ b/src/components/settings/EnvironmentVariables/index.tsx @@ -3,8 +3,20 @@ import { Paper, Grid, Typography, TextField, Button, Tooltip, IconButton, SvgIco import InputAdornment from '@mui/material/InputAdornment' import RotateLeftIcon from '@mui/icons-material/RotateLeft' import { useAppDispatch, useAppSelector } from '@/store' -import { selectSettings, setIPFS, setRpc, setTenderly, setWalletConnectApiKey } from '@/store/settingsSlice' -import { CHAINLIST_URL } from '@/config/constants' +import { + selectSettings, + setHistoricalRpcLogBatchSize, + setHistoricalRpcLogMaxConcurrentRequests, + setIPFS, + setRpc, + setTenderly, + setWalletConnectApiKey, +} from '@/store/settingsSlice' +import { + CHAINLIST_URL, + HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE, + HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS, +} from '@/config/constants' import useChainId from '@/hooks/useChainId' import { useCurrentChain } from '@/hooks/useChains' import InfoIcon from '@/public/images/notifications/info.svg' @@ -14,6 +26,8 @@ import { useEffect, useState } from 'react' export enum EnvVariablesField { rpc = 'rpc', + historicalRpcLogBatchSize = 'historicalRpcLogBatchSize', + historicalRpcLogMaxConcurrentRequests = 'historicalRpcLogMaxConcurrentRequests', ipfs = 'ipfs', tenderlyOrgName = 'tenderlyOrgName', tenderlyProjectName = 'tenderlyProjectName', @@ -23,6 +37,8 @@ export enum EnvVariablesField { export type EnvVariablesFormData = { [EnvVariablesField.rpc]: string + [EnvVariablesField.historicalRpcLogBatchSize]: number + [EnvVariablesField.historicalRpcLogMaxConcurrentRequests]: number [EnvVariablesField.ipfs]: string [EnvVariablesField.tenderlyOrgName]: string [EnvVariablesField.tenderlyProjectName]: string @@ -40,6 +56,10 @@ const EnvironmentVariables = () => { mode: 'onChange', values: { [EnvVariablesField.rpc]: settings.env?.rpc[chainId] ?? '', + [EnvVariablesField.historicalRpcLogBatchSize]: + settings.env?.historicalRpcLogBatchSize ?? HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE, + [EnvVariablesField.historicalRpcLogMaxConcurrentRequests]: + settings.env?.historicalRpcLogMaxConcurrentRequests ?? HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS, [EnvVariablesField.ipfs]: settings.env?.ipfs ?? '', [EnvVariablesField.tenderlyOrgName]: settings.env?.tenderly.orgName ?? '', [EnvVariablesField.tenderlyProjectName]: settings.env?.tenderly.projectName ?? '', @@ -79,6 +99,11 @@ const EnvironmentVariables = () => { }), ) + dispatch(setHistoricalRpcLogBatchSize(Number(data[EnvVariablesField.historicalRpcLogBatchSize]))) + dispatch( + setHistoricalRpcLogMaxConcurrentRequests(Number(data[EnvVariablesField.historicalRpcLogMaxConcurrentRequests])), + ) + // strip ending slash if present if (data[EnvVariablesField.ipfs].endsWith('/')) { data[EnvVariablesField.ipfs] = data[EnvVariablesField.ipfs].slice(0, -1) @@ -184,6 +209,38 @@ const EnvironmentVariables = () => { fullWidth /> + + Chain queries + + + + + Number.isInteger(value) && value > 0, + })} + variant="outlined" + label="Historical log block batch size" + type="number" + fullWidth + /> + + + + Number.isInteger(value) && value > 0, + })} + variant="outlined" + label="Max parallel historical log requests" + type="number" + fullWidth + /> + + + IPFS URL { + beforeEach(() => { + localStorage.clear() + jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ + safeAddress: '0xSafe', + safe: { chainId: '1' }, + safeLoaded: true, + safeLoading: false, + safeError: undefined, + } as any) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('shows scanning progress on the history route', () => { + render(, { + routerProps: { pathname: '/transactions/history' }, + initialReduxState: { + historicalRpcSync: { + txHistoryBySafe: { + '1:0xsafe': { + latestSyncedBlock: 100, + backfillCursor: 80, + backfillComplete: false, + }, + }, + }, + } as any, + }) + + expect(screen.getByText('Scanning history... block 80 of 100')).toBeInTheDocument() + }) + + it('shows completed progress once backfill reaches block zero', () => { + render(, { + routerProps: { pathname: '/transactions/history' }, + initialReduxState: { + historicalRpcSync: { + txHistoryBySafe: { + '1:0xsafe': { + latestSyncedBlock: 100, + backfillCursor: 0, + backfillComplete: true, + }, + }, + }, + } as any, + }) + + expect(screen.getByText('History scanned. Latest block: 100')).toBeInTheDocument() + }) +}) diff --git a/src/components/transactions/TxNavigation/index.tsx b/src/components/transactions/TxNavigation/index.tsx index d62eb61f..9a82445c 100644 --- a/src/components/transactions/TxNavigation/index.tsx +++ b/src/components/transactions/TxNavigation/index.tsx @@ -1,8 +1,38 @@ +import { Box, Typography } from '@mui/material' import NavTabs from '@/components/common/NavTabs' import { transactionNavItems } from '@/components/sidebar/SidebarNavigation/config' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useAppSelector } from '@/store' +import { AppRoutes } from '@/config/routes' +import { useRouter } from 'next/router' +import { buildTxHistorySyncKey, selectTxHistoryCursor } from '@/store/historicalRpcSyncSlice' const TxNavigation = () => { - return + const router = useRouter() + const { safe, safeAddress } = useSafeInfo() + const syncKey = safeAddress ? buildTxHistorySyncKey(safe.chainId, safeAddress) : '' + const cursor = useAppSelector((state) => (syncKey ? selectTxHistoryCursor(state, syncKey) : undefined)) + + const isHistorySelected = + router.pathname === AppRoutes.transactions.history || router.pathname === AppRoutes.transactions.index + + return ( + + + + {isHistorySelected && cursor && !cursor.backfillComplete && ( + + {`Scanning history... block ${cursor.backfillCursor} of ${cursor.latestSyncedBlock}`} + + )} + + {isHistorySelected && cursor?.backfillComplete && ( + + {`History scanned. Latest block: ${cursor.latestSyncedBlock}`} + + )} + + ) } export default TxNavigation diff --git a/src/config/constants.ts b/src/config/constants.ts index 61b8a5fe..414ae0e9 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -3,9 +3,22 @@ import chains from './chains' export const IS_PRODUCTION = process.env.NEXT_PUBLIC_IS_PRODUCTION === 'true' export const IS_DEV = process.env.NODE_ENV === 'development' +export const getPositiveIntegerOrDefault = (value: unknown, fallback: number): number => { + const parsedValue = typeof value === 'string' ? Number(value) : value + return typeof parsedValue === 'number' && Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : fallback +} + // Magic numbers export const POLLING_INTERVAL = 15_000 export const BASE_TX_GAS = 21_000 +export const HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE = getPositiveIntegerOrDefault( + process.env.NEXT_PUBLIC_HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE, + 10_000, +) +export const HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS = getPositiveIntegerOrDefault( + process.env.NEXT_PUBLIC_HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS, + 4, +) export const LS_NAMESPACE = 'ETERNALSAFE__' export const LATEST_SAFE_VERSION = process.env.NEXT_PUBLIC_SAFE_VERSION || '1.4.1' diff --git a/src/hooks/__tests__/useLoadTxHistory.live.test.ts b/src/hooks/__tests__/useLoadTxHistory.live.test.ts new file mode 100644 index 00000000..52895b3a --- /dev/null +++ b/src/hooks/__tests__/useLoadTxHistory.live.test.ts @@ -0,0 +1,27 @@ +import { ethers } from 'ethers' + +const runLiveSepoliaTests = process.env.RUN_LIVE_SEPOLIA_TESTS === 'true' +const describeLive = runLiveSepoliaTests ? describe : describe.skip + +describeLive('useLoadTxHistory live Sepolia parsing', () => { + it('parses the suspected ExecutionSuccess log shape', async () => { + const provider = new ethers.providers.JsonRpcProvider('https://ethereum-sepolia-rpc.publicnode.com', { + name: 'sepolia', + chainId: 11155111, + }) + const safeAddress = '0x577A0D87f4e6fbdd55d51Ac4a4344EC042C04bb2' + const txHash = '0xe460983504ce8700f9847d397bf40c86955c4f1425501066d0b59763694dcfdf' + const blockNumber = 10_608_581 + const safe = new ethers.Contract(safeAddress, ['event ExecutionSuccess(bytes32 txHash, uint256 payment)'], provider) + + const [tx, logs] = await Promise.all([ + provider.getTransaction(txHash), + safe.queryFilter(safe.filters.ExecutionSuccess(), blockNumber, blockNumber), + ]) + + expect(tx?.hash).toBe(txHash) + expect(logs).toHaveLength(1) + expect(logs[0].transactionHash).toBe(txHash) + expect(logs[0].args?.txHash).toBe('0xb7056c6259d601cc1feed64e59c95d95cbcc911c62bb14cf783bfd3d809e609b') + }) +}) diff --git a/src/hooks/__tests__/useLoadTxHistory.test.ts b/src/hooks/__tests__/useLoadTxHistory.test.ts new file mode 100644 index 00000000..209ba026 --- /dev/null +++ b/src/hooks/__tests__/useLoadTxHistory.test.ts @@ -0,0 +1,787 @@ +import { Provider } from 'react-redux' +import { act, renderHook, waitFor } from '@testing-library/react' +import { BigNumber, constants } from 'ethers' +import React from 'react' + +import { makeStore } from '@/store' +import { historicalRpcSyncSlice, buildTxHistorySyncKey } from '@/store/historicalRpcSyncSlice' +import { settingsSlice } from '@/store/settingsSlice' +import { txHistorySlice } from '@/store/txHistorySlice' +import { getSafeContract } from '@/utils/safe-versions' +import { buildMultisigTxId } from '@/utils/tx-id' + +import useIntervalCounter from '../useIntervalCounter' +import useLoadTxHistory from '../loadables/useLoadTxHistory' +import * as safeInfo from '../useSafeInfo' +import * as web3 from '../wallets/web3' + +jest.mock('@/utils/safe-versions', () => ({ + getSafeContract: jest.fn(), +})) + +jest.mock('@/hooks/useIntervalCounter', () => ({ + __esModule: true, + default: jest.fn(() => [0, jest.fn()]), +})) + +const SAFE_ADDRESS = '0x0000000000000000000000000000000000000afe' +const CHAIN_ID = '1' +const SAFE_VERSION = '1.4.1' + +const createWrapper = (initialReduxState?: Record) => { + const store = makeStore(initialReduxState) + + return { + store, + wrapper: ({ children }: { children: React.ReactNode }) => + React.createElement( + Provider as React.ComponentType<{ store: typeof store; children?: React.ReactNode }>, + { store }, + children, + ), + } +} + +const createLog = (blockNumber: number, transactionHash: string, safeTxHash: string) => ({ + blockNumber, + transactionHash, + args: { + txHash: safeTxHash, + }, +}) + +const createDecodedTxData = () => [ + constants.AddressZero, + BigNumber.from(0), + '0x', + 0, + BigNumber.from(0), + BigNumber.from(0), + BigNumber.from(0), + constants.AddressZero, + constants.AddressZero, +] + +const createDecodedTxHistoryItem = (txId: string, safeTxHash: string, timestamp: number) => ({ + txId, + txHash: `${safeTxHash}-tx-hash`, + safeTxHash, + timestamp, + executor: constants.AddressZero, + decodedTxData: { + to: constants.AddressZero, + value: BigNumber.from(0), + data: '0x', + operation: 0, + safeTxGas: BigNumber.from(0), + baseGas: BigNumber.from(0), + gasPrice: BigNumber.from(0), + gasToken: constants.AddressZero, + refundReceiver: constants.AddressZero, + nonce: 0, + }, +}) + +describe('useLoadTxHistory', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(safeInfo, 'default').mockReturnValue({ + safeAddress: SAFE_ADDRESS, + safe: { chainId: CHAIN_ID, nonce: 3, version: SAFE_VERSION }, + } as any) + }) + + it('does not query execution logs from block 0 to latest', async () => { + const queryFilter = jest.fn().mockResolvedValue([]) + + ;(getSafeContract as jest.Mock).mockReturnValue({ + filters: { ExecutionSuccess: jest.fn(() => 'execution-filter') }, + queryFilter, + interface: { decodeFunctionData: jest.fn() }, + }) + + jest.spyOn(web3, 'useMultiWeb3ReadOnly').mockReturnValue({ + getBlockNumber: jest.fn().mockResolvedValue(25_000), + getBlock: jest.fn(), + getTransaction: jest.fn(), + } as any) + + const { wrapper } = createWrapper() + + await act(async () => { + renderHook(() => useLoadTxHistory(), { wrapper }) + }) + + await waitFor(() => expect(queryFilter).toHaveBeenCalled()) + + expect(queryFilter).not.toHaveBeenCalledWith('execution-filter', 0, 'latest') + expect(queryFilter).toHaveBeenCalledWith('execution-filter', 15_001, 25_000) + }) + + it('keeps log-derived history when tx details are temporarily unavailable and retries later', async () => { + const txId = buildMultisigTxId(SAFE_ADDRESS, '0xmissing') + const queryFilter = jest.fn().mockResolvedValue([createLog(4, '0xmissing-tx', '0xmissing')]) + const decodeFunctionData = jest.fn(() => createDecodedTxData()) + let pollCount = 0 + + ;(useIntervalCounter as jest.Mock).mockImplementation(() => [pollCount, jest.fn()]) + ;(getSafeContract as jest.Mock).mockReturnValue({ + filters: { ExecutionSuccess: jest.fn(() => 'execution-filter') }, + queryFilter, + interface: { decodeFunctionData }, + }) + + const getTransaction = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + from: '0x0000000000000000000000000000000000000002', + data: '0xabcdef00', + }) + + jest.spyOn(web3, 'useMultiWeb3ReadOnly').mockReturnValue({ + getBlockNumber: jest.fn().mockResolvedValue(4), + getBlock: jest.fn().mockResolvedValue({ timestamp: 4 }), + getTransaction, + } as any) + + const { wrapper } = createWrapper({ + [settingsSlice.name]: { + ...settingsSlice.getInitialState(), + env: { + ...settingsSlice.getInitialState().env, + historicalRpcLogBatchSize: 5, + historicalRpcLogMaxConcurrentRequests: 1, + }, + }, + }) + + const { result, rerender } = renderHook(() => useLoadTxHistory(), { wrapper }) + + await waitFor(() => + expect(result.current[0]?.[txId]).toEqual( + expect.objectContaining({ + txId, + txHash: '0xmissing-tx', + safeTxHash: '0xmissing', + timestamp: 4_000, + executor: constants.AddressZero, + txDetailsUnavailable: true, + }), + ), + ) + expect(result.current[1]).toBeUndefined() + expect(decodeFunctionData).not.toHaveBeenCalled() + + pollCount = 1 + rerender() + + await waitFor(() => + expect(result.current[0]?.[txId]).toEqual( + expect.objectContaining({ + executor: '0x0000000000000000000000000000000000000002', + txDetailsUnavailable: undefined, + decodedTxData: expect.objectContaining({ + nonce: 0, + }), + }), + ), + ) + expect(getTransaction).toHaveBeenCalledWith('0xmissing-tx') + expect(decodeFunctionData).toHaveBeenCalledWith('execTransaction', '0xabcdef00') + }) + + it('recovers execution success tx hash from raw log data when event args are null', async () => { + const safeTxHash = '0xb7056c6259d601cc1feed64e59c95d95cbcc911c62bb14cf783bfd3d809e609b' + const txId = buildMultisigTxId(SAFE_ADDRESS, safeTxHash) + const queryFilter = jest.fn().mockResolvedValue([ + { + blockNumber: 4, + transactionHash: '0xe460983504ce8700f9847d397bf40c86955c4f1425501066d0b59763694dcfdf', + args: null, + topics: ['0x442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e'], + data: `${safeTxHash}0000000000000000000000000000000000000000000000000000000000000000`, + }, + ]) + const parseLog = jest.fn().mockReturnValue({ args: { txHash: safeTxHash } }) + + ;(getSafeContract as jest.Mock).mockReturnValue({ + filters: { ExecutionSuccess: jest.fn(() => 'execution-filter') }, + queryFilter, + interface: { + decodeFunctionData: jest.fn(() => createDecodedTxData()), + parseLog, + }, + }) + + jest.spyOn(web3, 'useMultiWeb3ReadOnly').mockReturnValue({ + getBlockNumber: jest.fn().mockResolvedValue(4), + getBlock: jest.fn().mockResolvedValue({ timestamp: 4 }), + getTransaction: jest.fn().mockResolvedValue({ + from: constants.AddressZero, + data: '0xabcdef00', + }), + } as any) + + const { wrapper } = createWrapper() + + const { result } = renderHook(() => useLoadTxHistory(), { wrapper }) + + await waitFor(() => + expect(result.current[0]?.[txId]).toEqual( + expect.objectContaining({ + txId, + safeTxHash, + txHash: '0xe460983504ce8700f9847d397bf40c86955c4f1425501066d0b59763694dcfdf', + }), + ), + ) + expect(result.current[1]).toBeUndefined() + expect(parseLog).toHaveBeenCalled() + }) + + it('uses persisted history immediately and merges resumed backfill batches incrementally', async () => { + const persistedTxId = buildMultisigTxId(SAFE_ADDRESS, '0x111') + const existingHistory = { + [persistedTxId]: { + txId: persistedTxId, + txHash: '0xaaaa', + safeTxHash: '0x111', + timestamp: 5_000, + executor: constants.AddressZero, + }, + } + const syncKey = buildTxHistorySyncKey(CHAIN_ID, SAFE_ADDRESS) + + let resolveSecondRange: ((logs: Array>) => void) | undefined + let resolveFinalRange: ((logs: Array>) => void) | undefined + const secondRangePromise = new Promise>>((resolve) => { + resolveSecondRange = resolve + }) + const finalRangePromise = new Promise>>((resolve) => { + resolveFinalRange = resolve + }) + + const queryFilter = jest.fn((filter, fromBlock: number, toBlock: number) => { + if (filter !== 'execution-filter') { + return Promise.resolve([]) + } + + if (fromBlock === 6 && toBlock === 10) { + return Promise.resolve([createLog(8, '0xbbbb', '0x222')]) + } + + if (fromBlock === 1 && toBlock === 5) { + return secondRangePromise + } + + if (fromBlock === 0 && toBlock === 0) { + return finalRangePromise + } + + return Promise.resolve([]) + }) + + ;(getSafeContract as jest.Mock).mockReturnValue({ + filters: { ExecutionSuccess: jest.fn(() => 'execution-filter') }, + queryFilter, + interface: { decodeFunctionData: jest.fn(() => createDecodedTxData()) }, + }) + + jest.spyOn(web3, 'useMultiWeb3ReadOnly').mockReturnValue({ + getBlockNumber: jest.fn().mockResolvedValue(30), + getBlock: jest.fn((blockNumber: number) => + Promise.resolve({ + timestamp: blockNumber <= 8 ? 8 : blockNumber, + }), + ), + getTransaction: jest.fn((transactionHash: string) => + Promise.resolve({ + from: constants.AddressZero, + data: transactionHash === '0xcccc' ? '0xabcdef01' : '0xabcdef00', + }), + ), + } as any) + + const initialReduxState = { + [settingsSlice.name]: { + ...settingsSlice.getInitialState(), + env: { + ...settingsSlice.getInitialState().env, + historicalRpcLogBatchSize: 5, + historicalRpcLogMaxConcurrentRequests: 1, + }, + }, + [txHistorySlice.name]: { + data: existingHistory, + loading: false, + syncKey, + }, + [historicalRpcSyncSlice.name]: { + txHistoryBySafe: { + [syncKey]: { + latestSyncedBlock: 30, + backfillCursor: 10, + backfillComplete: false, + }, + }, + }, + } + + const { store, wrapper } = createWrapper(initialReduxState) + let result: { current: ReturnType } | undefined + + await act(async () => { + ;({ result } = renderHook(() => useLoadTxHistory(), { wrapper })) + }) + + expect(result!.current[0]).toEqual(expect.objectContaining(existingHistory)) + + const firstMergedTxId = buildMultisigTxId(SAFE_ADDRESS, '0x222') + await waitFor(() => + expect(result!.current[0]).toEqual( + expect.objectContaining({ + [persistedTxId]: existingHistory[persistedTxId], + [firstMergedTxId]: expect.objectContaining({ + txId: firstMergedTxId, + safeTxHash: '0x222', + }), + }), + ), + ) + + expect(queryFilter).toHaveBeenCalledWith('execution-filter', 6, 10) + expect(queryFilter).toHaveBeenCalledWith('execution-filter', 1, 5) + expect(queryFilter).not.toHaveBeenCalledWith('execution-filter', 0, 'latest') + + await act(async () => { + resolveSecondRange?.([createLog(3, '0xcccc', '0x333'), createLog(3, '0xdddd', '0x000')]) + }) + + const secondMergedTxId = buildMultisigTxId(SAFE_ADDRESS, '0x333') + const thirdMergedTxId = buildMultisigTxId(SAFE_ADDRESS, '0x000') + await waitFor(() => + expect(result!.current[0]).toEqual( + expect.objectContaining({ + [persistedTxId]: existingHistory[persistedTxId], + [firstMergedTxId]: expect.any(Object), + [secondMergedTxId]: expect.objectContaining({ + txId: secondMergedTxId, + safeTxHash: '0x333', + }), + [thirdMergedTxId]: expect.objectContaining({ + txId: thirdMergedTxId, + safeTxHash: '0x000', + }), + }), + ), + ) + + expect(Object.keys(result!.current[0] || {})).toEqual([ + secondMergedTxId, + thirdMergedTxId, + firstMergedTxId, + persistedTxId, + ]) + expect(result!.current[0]?.[secondMergedTxId]?.decodedTxData?.nonce).toBe(0) + expect(result!.current[0]?.[thirdMergedTxId]?.decodedTxData?.nonce).toBe(1) + expect(result!.current[0]?.[firstMergedTxId]?.decodedTxData?.nonce).toBe(2) + + await waitFor(() => + expect(store.getState()[historicalRpcSyncSlice.name].txHistoryBySafe[syncKey]).toEqual({ + latestSyncedBlock: 30, + backfillCursor: 0, + backfillComplete: false, + }), + ) + + expect(queryFilter).toHaveBeenCalledWith('execution-filter', 0, 0) + + await act(async () => { + resolveFinalRange?.([]) + }) + + await waitFor(() => + expect(store.getState()[historicalRpcSyncSlice.name].txHistoryBySafe[syncKey]).toEqual({ + latestSyncedBlock: 30, + backfillCursor: 0, + backfillComplete: true, + }), + ) + }) + + it('merges persisted snapshots for the same safe without dropping local history', async () => { + const syncKey = buildTxHistorySyncKey(CHAIN_ID, SAFE_ADDRESS) + const localTxId = buildMultisigTxId(SAFE_ADDRESS, '0xaaa') + const persistedTxId = buildMultisigTxId(SAFE_ADDRESS, '0xbbb') + + const queryFilter = jest.fn().mockImplementation((_filter, fromBlock: number, toBlock: number) => { + if (fromBlock === 0 && toBlock === 4) { + return Promise.resolve([createLog(4, '0xaaaa', '0xaaa')]) + } + + return Promise.resolve([]) + }) + + ;(getSafeContract as jest.Mock).mockReturnValue({ + filters: { ExecutionSuccess: jest.fn(() => 'execution-filter') }, + queryFilter, + interface: { decodeFunctionData: jest.fn(() => createDecodedTxData()) }, + }) + + jest.spyOn(web3, 'useMultiWeb3ReadOnly').mockReturnValue({ + getBlockNumber: jest.fn().mockResolvedValue(4), + getBlock: jest.fn((blockNumber: number) => Promise.resolve({ timestamp: blockNumber })), + getTransaction: jest.fn(() => Promise.resolve({ from: constants.AddressZero, data: '0xabcdef00' })), + } as any) + + const initialReduxState = { + [settingsSlice.name]: { + ...settingsSlice.getInitialState(), + env: { + ...settingsSlice.getInitialState().env, + historicalRpcLogBatchSize: 5, + historicalRpcLogMaxConcurrentRequests: 1, + }, + }, + [historicalRpcSyncSlice.name]: { + txHistoryBySafe: { + [syncKey]: { + latestSyncedBlock: 4, + backfillCursor: 4, + backfillComplete: false, + }, + }, + }, + } + + const { store, wrapper } = createWrapper(initialReduxState) + let result: { current: ReturnType } | undefined + + await act(async () => { + ;({ result } = renderHook(() => useLoadTxHistory(), { wrapper })) + }) + + await waitFor(() => + expect(result!.current[0]).toEqual( + expect.objectContaining({ + [localTxId]: expect.objectContaining({ + txId: localTxId, + }), + }), + ), + ) + + act(() => { + store.dispatch( + txHistorySlice.actions.set({ + data: { + [persistedTxId]: { + txId: persistedTxId, + txHash: '0xbbbb', + safeTxHash: '0xbbb', + timestamp: 2_000, + executor: constants.AddressZero, + }, + }, + loading: false, + error: undefined, + syncKey, + }), + ) + }) + + await waitFor(() => + expect(result!.current[0]).toEqual( + expect.objectContaining({ + [localTxId]: expect.any(Object), + [persistedTxId]: expect.objectContaining({ + txId: persistedTxId, + }), + }), + ), + ) + }) + + it('does not bootstrap persisted tx history from another chain when the current chain also has a cursor', async () => { + jest.spyOn(safeInfo, 'default').mockReturnValue({ + safeAddress: SAFE_ADDRESS, + safe: { chainId: '2', nonce: 3, version: SAFE_VERSION }, + } as any) + ;(getSafeContract as jest.Mock).mockReturnValue({ + filters: { ExecutionSuccess: jest.fn(() => 'execution-filter') }, + queryFilter: jest.fn().mockResolvedValue([]), + interface: { decodeFunctionData: jest.fn() }, + }) + + jest.spyOn(web3, 'useMultiWeb3ReadOnly').mockReturnValue({ + getBlockNumber: jest.fn().mockResolvedValue(0), + getBlock: jest.fn(), + getTransaction: jest.fn(), + } as any) + + const chainOneSyncKey = buildTxHistorySyncKey(CHAIN_ID, SAFE_ADDRESS) + const chainTwoSyncKey = buildTxHistorySyncKey('2', SAFE_ADDRESS) + const persistedTxId = buildMultisigTxId(SAFE_ADDRESS, '0xabc') + const { wrapper } = createWrapper({ + [txHistorySlice.name]: { + data: { + [persistedTxId]: { + txId: persistedTxId, + txHash: '0xhash', + safeTxHash: '0xabc', + timestamp: 1_000, + executor: constants.AddressZero, + }, + }, + loading: false, + syncKey: chainOneSyncKey, + }, + [historicalRpcSyncSlice.name]: { + txHistoryBySafe: { + [chainOneSyncKey]: { + latestSyncedBlock: 10, + backfillCursor: 0, + backfillComplete: true, + }, + [chainTwoSyncKey]: { + latestSyncedBlock: 8, + backfillCursor: 0, + backfillComplete: false, + }, + }, + }, + }) + + let result: { current: ReturnType } | undefined + + await act(async () => { + ;({ result } = renderHook(() => useLoadTxHistory(), { wrapper })) + }) + + expect(result!.current[0]).toBeUndefined() + }) + + it('queries block 0 on resume before marking backfill complete', async () => { + const syncKey = buildTxHistorySyncKey(CHAIN_ID, SAFE_ADDRESS) + const zeroTxId = buildMultisigTxId(SAFE_ADDRESS, '0xzero') + const queryFilter = jest.fn((filter, fromBlock: number, toBlock: number) => { + if (filter !== 'execution-filter') { + return Promise.resolve([]) + } + + if (fromBlock === 0 && toBlock === 0) { + return Promise.resolve([createLog(0, '0xzero-hash', '0xzero')]) + } + + return Promise.resolve([]) + }) + + ;(getSafeContract as jest.Mock).mockReturnValue({ + filters: { ExecutionSuccess: jest.fn(() => 'execution-filter') }, + queryFilter, + interface: { decodeFunctionData: jest.fn(() => createDecodedTxData()) }, + }) + + jest.spyOn(web3, 'useMultiWeb3ReadOnly').mockReturnValue({ + getBlockNumber: jest.fn().mockResolvedValue(25), + getBlock: jest.fn((blockNumber: number) => Promise.resolve({ timestamp: blockNumber })), + getTransaction: jest.fn(() => + Promise.resolve({ + from: constants.AddressZero, + data: '0xzero-data', + }), + ), + } as any) + + const { store, wrapper } = createWrapper({ + [settingsSlice.name]: { + ...settingsSlice.getInitialState(), + env: { + ...settingsSlice.getInitialState().env, + historicalRpcLogBatchSize: 5, + historicalRpcLogMaxConcurrentRequests: 1, + }, + }, + [historicalRpcSyncSlice.name]: { + txHistoryBySafe: { + [syncKey]: { + latestSyncedBlock: 25, + backfillCursor: 0, + backfillComplete: false, + }, + }, + }, + }) + + let result: { current: ReturnType } | undefined + + await act(async () => { + ;({ result } = renderHook(() => useLoadTxHistory(), { wrapper })) + }) + + await waitFor(() => expect(queryFilter).toHaveBeenCalledWith('execution-filter', 0, 0)) + await waitFor(() => + expect(result!.current[0]).toEqual( + expect.objectContaining({ + [zeroTxId]: expect.objectContaining({ + txId: zeroTxId, + safeTxHash: '0xzero', + }), + }), + ), + ) + await waitFor(() => + expect(store.getState()[historicalRpcSyncSlice.name].txHistoryBySafe[syncKey]).toEqual({ + latestSyncedBlock: 25, + backfillCursor: 0, + backfillComplete: true, + }), + ) + }) + + it('keeps older backfill executions ahead of newer head sync executions when assigning nonces', async () => { + const syncKey = buildTxHistorySyncKey(CHAIN_ID, SAFE_ADDRESS) + const middleTxId = buildMultisigTxId(SAFE_ADDRESS, '0xmid') + const olderTxId = buildMultisigTxId(SAFE_ADDRESS, '0xold') + const newerTxId = buildMultisigTxId(SAFE_ADDRESS, '0xnew') + + const queryFilter = jest.fn((filter, fromBlock: number, toBlock: number) => { + if (filter !== 'execution-filter') { + return Promise.resolve([]) + } + + if (fromBlock === 11 && toBlock === 15) { + return Promise.resolve([createLog(12, '0xnew-hash', '0xnew')]) + } + + if (fromBlock === 1 && toBlock === 5) { + return Promise.resolve([createLog(2, '0xold-hash', '0xold')]) + } + + if (fromBlock === 0 && toBlock === 0) { + return Promise.resolve([]) + } + + return Promise.resolve([]) + }) + + ;(getSafeContract as jest.Mock).mockReturnValue({ + filters: { ExecutionSuccess: jest.fn(() => 'execution-filter') }, + queryFilter, + interface: { decodeFunctionData: jest.fn(() => createDecodedTxData()) }, + }) + + jest.spyOn(web3, 'useMultiWeb3ReadOnly').mockReturnValue({ + getBlockNumber: jest.fn().mockResolvedValue(15), + getBlock: jest.fn((blockNumber: number) => Promise.resolve({ timestamp: blockNumber })), + getTransaction: jest.fn((transactionHash: string) => + Promise.resolve({ + from: constants.AddressZero, + data: transactionHash, + }), + ), + } as any) + + const initialReduxState = { + [settingsSlice.name]: { + ...settingsSlice.getInitialState(), + env: { + ...settingsSlice.getInitialState().env, + historicalRpcLogBatchSize: 5, + historicalRpcLogMaxConcurrentRequests: 1, + }, + }, + [txHistorySlice.name]: { + data: { + [middleTxId]: createDecodedTxHistoryItem(middleTxId, '0xmid', 9_000), + }, + loading: false, + syncKey, + }, + [historicalRpcSyncSlice.name]: { + txHistoryBySafe: { + [syncKey]: { + latestSyncedBlock: 10, + backfillCursor: 5, + backfillComplete: false, + }, + }, + }, + } + + const { wrapper } = createWrapper(initialReduxState) + let result: { current: ReturnType } | undefined + + await act(async () => { + ;({ result } = renderHook(() => useLoadTxHistory(), { wrapper })) + }) + + await waitFor(() => expect(Object.keys(result!.current[0] || {})).toEqual([olderTxId, middleTxId, newerTxId])) + + expect(result!.current[0]?.[olderTxId]?.decodedTxData?.nonce).toBe(0) + expect(result!.current[0]?.[middleTxId]?.decodedTxData?.nonce).toBe(1) + expect(result!.current[0]?.[newerTxId]?.decodedTxData?.nonce).toBe(2) + }) + + it('preserves nonce spacing when an execution cannot be decoded', async () => { + const undecodableTxId = buildMultisigTxId(SAFE_ADDRESS, '0xbad') + const decodableTxId = buildMultisigTxId(SAFE_ADDRESS, '0xgood') + + const queryFilter = jest.fn((filter, fromBlock: number, toBlock: number) => { + if (filter !== 'execution-filter') { + return Promise.resolve([]) + } + + if (fromBlock === 0 && toBlock === 4) { + return Promise.resolve([createLog(1, '0xbad-hash', '0xbad'), createLog(2, '0xgood-hash', '0xgood')]) + } + + return Promise.resolve([]) + }) + + ;(getSafeContract as jest.Mock).mockReturnValue({ + filters: { ExecutionSuccess: jest.fn(() => 'execution-filter') }, + queryFilter, + interface: { + decodeFunctionData: jest.fn((_, data: string) => { + if (data === '0xbad-data') { + throw new Error('cannot decode') + } + + return createDecodedTxData() + }), + }, + }) + + jest.spyOn(web3, 'useMultiWeb3ReadOnly').mockReturnValue({ + getBlockNumber: jest.fn().mockResolvedValue(4), + getBlock: jest.fn((blockNumber: number) => Promise.resolve({ timestamp: blockNumber })), + getTransaction: jest.fn((transactionHash: string) => + Promise.resolve({ + from: constants.AddressZero, + data: transactionHash === '0xbad-hash' ? '0xbad-data' : '0xgood-data', + }), + ), + } as any) + + const { wrapper } = createWrapper({ + [settingsSlice.name]: { + ...settingsSlice.getInitialState(), + env: { + ...settingsSlice.getInitialState().env, + historicalRpcLogBatchSize: 5, + historicalRpcLogMaxConcurrentRequests: 1, + }, + }, + }) + + let result: { current: ReturnType } | undefined + + await act(async () => { + ;({ result } = renderHook(() => useLoadTxHistory(), { wrapper })) + }) + + await waitFor(() => expect(Object.keys(result!.current[0] || {})).toEqual([undecodableTxId, decodableTxId])) + + expect(result!.current[0]?.[undecodableTxId]?.decodedTxData).toBeUndefined() + expect(result!.current[0]?.[decodableTxId]?.decodedTxData?.nonce).toBe(1) + }) +}) diff --git a/src/hooks/__tests__/useLoadableStores.test.ts b/src/hooks/__tests__/useLoadableStores.test.ts new file mode 100644 index 00000000..12e95943 --- /dev/null +++ b/src/hooks/__tests__/useLoadableStores.test.ts @@ -0,0 +1,137 @@ +import { renderHook } from '@testing-library/react' + +import { useAppDispatch } from '@/store' +import { txHistorySlice } from '@/store/txHistorySlice' + +import useLoadableStores from '../useLoadableStores' +import useSafeInfo from '../useSafeInfo' +import useLoadBalances from '../loadables/useLoadBalances' +import useLoadChains from '../loadables/useLoadChains' +import useLoadCollectiblesBalances from '../loadables/useLoadCollectiblesBalance' +import useLoadSafeInfo from '../loadables/useLoadSafeInfo' +import useLoadSpendingLimits from '../loadables/useLoadSpendingLimits' +import useLoadTxHistory from '../loadables/useLoadTxHistory' +import useLoadTxQueue from '../loadables/useLoadTxQueue' + +jest.mock('@/store', () => ({ + useAppDispatch: jest.fn(), +})) + +jest.mock('../useSafeInfo', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('../loadables/useLoadChains', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('../loadables/useLoadSafeInfo', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('../loadables/useLoadBalances', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('../loadables/useLoadTxHistory', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('../loadables/useLoadTxQueue', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('../loadables/useLoadCollectiblesBalance', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('../loadables/useLoadSpendingLimits', () => ({ + __esModule: true, + default: jest.fn(), +})) + +const txHistoryItem = { + txId: 'tx', + txHash: '0xtx', + safeTxHash: '0xsafe', + timestamp: 1, + executor: '0x0000000000000000000000000000000000000000', +} + +describe('useLoadableStores', () => { + it('tags tx history store writes with the active sync key', () => { + const dispatch = jest.fn() + + ;(useAppDispatch as jest.Mock).mockReturnValue(dispatch) + ;(useSafeInfo as jest.Mock).mockReturnValue({ + safeAddress: '0x0000000000000000000000000000000000000afe', + safe: { chainId: '1', nonce: 0, version: '1.4.1' }, + }) + ;(useLoadChains as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadSafeInfo as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadBalances as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadTxHistory as jest.Mock).mockReturnValue([{ tx: txHistoryItem }, undefined, false]) + ;(useLoadTxQueue as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadCollectiblesBalances as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadSpendingLimits as jest.Mock).mockReturnValue([undefined, undefined, false]) + + renderHook(() => useLoadableStores()) + + expect(dispatch).toHaveBeenCalledWith( + txHistorySlice.actions.set({ + data: { tx: txHistoryItem }, + error: undefined, + loading: false, + syncKey: '1:0x0000000000000000000000000000000000000afe', + }), + ) + }) + + it('clears tx history on the first render after a sync key switch before restamping new data', () => { + const dispatch = jest.fn() + let currentChainId = '1' + + ;(useAppDispatch as jest.Mock).mockReturnValue(dispatch) + ;(useSafeInfo as jest.Mock).mockImplementation(() => ({ + safeAddress: '0x0000000000000000000000000000000000000afe', + safe: { chainId: currentChainId, nonce: 0, version: '1.4.1' }, + })) + ;(useLoadChains as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadSafeInfo as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadBalances as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadTxHistory as jest.Mock).mockReturnValue([{ tx: txHistoryItem }, undefined, false]) + ;(useLoadTxQueue as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadCollectiblesBalances as jest.Mock).mockReturnValue([undefined, undefined, false]) + ;(useLoadSpendingLimits as jest.Mock).mockReturnValue([undefined, undefined, false]) + + const { rerender } = renderHook(() => useLoadableStores()) + + dispatch.mockClear() + currentChainId = '2' + rerender() + + expect(dispatch).toHaveBeenCalledWith( + txHistorySlice.actions.set({ + data: undefined, + error: undefined, + loading: false, + syncKey: '2:0x0000000000000000000000000000000000000afe', + }), + ) + expect(dispatch).not.toHaveBeenCalledWith( + txHistorySlice.actions.set({ + data: { tx: txHistoryItem }, + error: undefined, + loading: false, + syncKey: '2:0x0000000000000000000000000000000000000afe', + }), + ) + }) +}) diff --git a/src/hooks/__tests__/useTxHistory.test.ts b/src/hooks/__tests__/useTxHistory.test.ts new file mode 100644 index 00000000..a41ce647 --- /dev/null +++ b/src/hooks/__tests__/useTxHistory.test.ts @@ -0,0 +1,60 @@ +import { Provider } from 'react-redux' +import { renderHook, waitFor } from '@testing-library/react' +import React from 'react' + +import { makeStore } from '@/store' +import { txHistorySlice } from '@/store/txHistorySlice' +import { buildMultisigTxId } from '@/utils/tx-id' + +import useTxHistory from '../useTxHistory' +import * as safeInfo from '../useSafeInfo' + +jest.mock('../useSafeInfo') + +const SAFE_ADDRESS = '0x0000000000000000000000000000000000000afe' +const CHAIN_ID = '11155111' + +describe('useTxHistory', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(safeInfo, 'default').mockReturnValue({ + safeAddress: SAFE_ADDRESS, + safe: { chainId: CHAIN_ID, nonce: 0, version: '1.4.1' }, + } as any) + }) + + it('ignores null persisted tx history entries', async () => { + const txId = buildMultisigTxId(SAFE_ADDRESS, '0xsafe') + const store = makeStore({ + [txHistorySlice.name]: { + loading: false, + syncKey: `${CHAIN_ID}:${SAFE_ADDRESS}`, + data: { + nullEntry: null, + [txId]: { + txId, + txHash: '0xtx', + safeTxHash: '0xsafe', + timestamp: 1, + executor: '0x0000000000000000000000000000000000000001', + }, + }, + }, + } as any) + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + Provider as React.ComponentType<{ store: typeof store; children?: React.ReactNode }>, + { store }, + children, + ) + + const { result } = renderHook(() => useTxHistory(), { wrapper }) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.error).toBeUndefined() + expect(result.current.data).toHaveLength(1) + expect(result.current.data[0].details.txHash).toBe('0xtx') + }) +}) diff --git a/src/hooks/loadables/txHistory/types.ts b/src/hooks/loadables/txHistory/types.ts new file mode 100644 index 00000000..fba283f7 --- /dev/null +++ b/src/hooks/loadables/txHistory/types.ts @@ -0,0 +1,28 @@ +import type { SafeTransactionData } from '@safe-global/safe-core-sdk-types' + +export type TxHistoryItem = { + txId: string + txHash: string + safeTxHash: string + timestamp: number + executor: string + decodedTxData?: SafeTransactionData + txDetailsUnavailable?: boolean +} + +export type TxHistory = { + [txId: string]: TxHistoryItem +} + +export const isTxHistoryItem = (value: unknown): value is TxHistoryItem => { + const item = value as Partial | null + + return ( + !!item && + typeof item.txId === 'string' && + typeof item.txHash === 'string' && + typeof item.safeTxHash === 'string' && + typeof item.timestamp === 'number' && + typeof item.executor === 'string' + ) +} diff --git a/src/hooks/loadables/txHistory/useTxHistoryLoader.ts b/src/hooks/loadables/txHistory/useTxHistoryLoader.ts new file mode 100644 index 00000000..8aa25a50 --- /dev/null +++ b/src/hooks/loadables/txHistory/useTxHistoryLoader.ts @@ -0,0 +1,552 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import isEqual from 'lodash/isEqual' +import type { SafeTransactionData } from '@safe-global/safe-core-sdk-types' +import type { Result } from 'ethers/lib/utils' +import { constants } from 'ethers' + +import useSafeInfo from '@/hooks/useSafeInfo' +import useIntervalCounter from '@/hooks/useIntervalCounter' +import { POLLING_INTERVAL } from '@/config/constants' +import { useMultiWeb3ReadOnly } from '@/hooks/wallets/web3' +import { useAppDispatch, useAppSelector } from '@/store' +import { + buildTxHistorySyncKey, + type TxHistoryBackfillCursor, + selectTxHistoryCursor, + setTxHistoryCursor, +} from '@/store/historicalRpcSyncSlice' +import { selectHistoricalRpcLogBatchSize, selectHistoricalRpcLogMaxConcurrentRequests } from '@/store/settingsSlice' +import { selectTxHistory } from '@/store/txHistorySlice' +import { asError } from '@/services/exceptions/utils' +import { getSafeContract } from '@/utils/safe-versions' +import { buildMultisigTxId } from '@/utils/tx-id' +import { queryFilterBackwards, type BlockRange } from '@/utils/queryFilterBackfill' + +import { isTxHistoryItem, type TxHistory, type TxHistoryItem } from './types' + +type UseTxHistoryLoaderResult = { + data: TxHistory | undefined + error: Error | undefined + loading: boolean +} + +type ExecutionSuccessLog = { + blockNumber: number + transactionHash: string + topics?: string[] + data?: string + args: { + txHash?: string + [index: number]: string | undefined + } | null +} + +const INITIAL_CURSOR: TxHistoryBackfillCursor = { + latestSyncedBlock: 0, + backfillCursor: 0, + backfillComplete: false, +} + +const parseDecodedTxData = (decodedTxData: Result, nonce: number): SafeTransactionData => { + const [to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver] = decodedTxData + return { + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce, + } +} + +const getSafeAddressFromTxId = (txId: string): string | undefined => { + const [, safeAddress] = txId.split('_') + return safeAddress?.toLowerCase() +} + +const filterHistoryForSafe = (history: TxHistory | undefined, safeAddress: string): TxHistory | undefined => { + const normalizedSafeAddress = safeAddress.toLowerCase() + const entries = Object.entries(history ?? {}).filter(([txId, item]) => { + return isTxHistoryItem(item) && getSafeAddressFromTxId(txId) === normalizedSafeAddress + }) + + if (!entries.length) { + return undefined + } + + return Object.fromEntries(entries) +} + +const getOrderedHistoryItems = (history: TxHistory | undefined): TxHistoryItem[] => { + return Object.values(history ?? {}).filter(isTxHistoryItem) +} + +const mergeTxHistoryItem = (current: TxHistoryItem | undefined, next: TxHistoryItem): TxHistoryItem => { + return { + ...current, + ...next, + decodedTxData: next.decodedTxData ?? current?.decodedTxData, + } +} + +const rebuildHistoryFromOrder = (orderedItems: TxHistoryItem[]): TxHistory | undefined => { + if (!orderedItems.length) { + return undefined + } + + return orderedItems.reduce((acc, item, index) => { + acc[item.txId] = item.decodedTxData + ? { + ...item, + decodedTxData: { + ...item.decodedTxData, + nonce: index, + }, + } + : item + + return acc + }, {}) +} + +const insertRangeIntoOrderedHistory = ( + currentOrderedHistory: TxHistoryItem[], + nextItems: TxHistoryItem[], + insertionIndex: number, +) => { + const currentById = new Map(currentOrderedHistory.map((item) => [item.txId, item])) + const nextTxIds = new Set(nextItems.map((item) => item.txId)) + const removedBeforeIndex = currentOrderedHistory + .slice(0, insertionIndex) + .filter((item) => nextTxIds.has(item.txId)).length + const remainingItems = currentOrderedHistory.filter((item) => !nextTxIds.has(item.txId)) + const normalizedInsertionIndex = Math.max(0, Math.min(remainingItems.length, insertionIndex - removedBeforeIndex)) + const mergedItems = nextItems.map((item) => mergeTxHistoryItem(currentById.get(item.txId), item)) + + return { + orderedHistory: [ + ...remainingItems.slice(0, normalizedInsertionIndex), + ...mergedItems, + ...remainingItems.slice(normalizedInsertionIndex), + ], + nextInsertionIndex: normalizedInsertionIndex + mergedItems.length, + } +} + +const mergePersistedHistorySnapshot = ( + currentOrderedHistory: TxHistoryItem[], + persistedHistory: TxHistory | undefined, +): TxHistoryItem[] => { + const persistedOrderedHistory = getOrderedHistoryItems(persistedHistory) + if (!persistedOrderedHistory.length) { + return currentOrderedHistory + } + + if (!currentOrderedHistory.length) { + return persistedOrderedHistory + } + + const nextOrderedHistory = currentOrderedHistory.slice() + + for (const [snapshotIndex, snapshotItem] of persistedOrderedHistory.entries()) { + const currentIndex = nextOrderedHistory.findIndex((item) => item.txId === snapshotItem.txId) + + if (currentIndex >= 0) { + nextOrderedHistory[currentIndex] = mergeTxHistoryItem(nextOrderedHistory[currentIndex], snapshotItem) + continue + } + + let insertAt = nextOrderedHistory.length + + for (let lookAheadIndex = snapshotIndex + 1; lookAheadIndex < persistedOrderedHistory.length; lookAheadIndex += 1) { + const futureIndex = nextOrderedHistory.findIndex( + (item) => item.txId === persistedOrderedHistory[lookAheadIndex].txId, + ) + if (futureIndex >= 0) { + insertAt = futureIndex + break + } + } + + if (insertAt === nextOrderedHistory.length) { + for (let lookBackIndex = snapshotIndex - 1; lookBackIndex >= 0; lookBackIndex -= 1) { + const previousIndex = nextOrderedHistory.findIndex( + (item) => item.txId === persistedOrderedHistory[lookBackIndex].txId, + ) + if (previousIndex >= 0) { + insertAt = previousIndex + 1 + break + } + } + } + + nextOrderedHistory.splice(insertAt, 0, snapshotItem) + } + + return nextOrderedHistory +} + +const mergeCursor = ( + current: TxHistoryBackfillCursor | undefined, + next: TxHistoryBackfillCursor, +): TxHistoryBackfillCursor => { + if (!current) { + return next + } + + const backfillComplete = current.backfillComplete || next.backfillComplete + + return { + latestSyncedBlock: Math.max(current.latestSyncedBlock, next.latestSyncedBlock), + backfillCursor: backfillComplete ? 0 : Math.min(current.backfillCursor, next.backfillCursor), + backfillComplete, + } +} + +const buildCursorAfterRange = ( + current: TxHistoryBackfillCursor | undefined, + latestSyncedBlock: number, + range: BlockRange, +): TxHistoryBackfillCursor => { + const candidate = { + latestSyncedBlock, + backfillCursor: Math.max(0, range.fromBlock - 1), + backfillComplete: range.fromBlock === 0, + } + + return mergeCursor(current, candidate) +} + +const createInitialCursor = (latestBlock: number): TxHistoryBackfillCursor => ({ + latestSyncedBlock: latestBlock, + backfillCursor: latestBlock, + backfillComplete: false, +}) + +export const useTxHistoryLoader = (): UseTxHistoryLoaderResult => { + const dispatch = useAppDispatch() + const provider = useMultiWeb3ReadOnly() + const { safe, safeAddress } = useSafeInfo() + const { chainId } = safe + const [pollCount, resetPolling] = useIntervalCounter(POLLING_INTERVAL) + const syncKey = useMemo(() => { + return safeAddress ? buildTxHistorySyncKey(chainId, safeAddress) : undefined + }, [chainId, safeAddress]) + + const batchSize = useAppSelector(selectHistoricalRpcLogBatchSize) + const maxConcurrentRequests = useAppSelector(selectHistoricalRpcLogMaxConcurrentRequests) + const persistedCursor = useAppSelector( + (state) => (syncKey ? selectTxHistoryCursor(state, syncKey) : undefined), + isEqual, + ) + const persistedTxHistory = useAppSelector((state) => { + const persistedTxHistoryState = selectTxHistory(state) + + if ( + !safeAddress || + !syncKey || + !selectTxHistoryCursor(state, syncKey) || + persistedTxHistoryState.syncKey !== syncKey + ) { + return undefined + } + + return filterHistoryForSafe(persistedTxHistoryState.data, safeAddress) + }, isEqual) + + const [data, setData] = useState(persistedTxHistory) + const [error, setError] = useState() + const [loading, setLoading] = useState(false) + const dataRef = useRef(persistedTxHistory) + const orderedHistoryRef = useRef(getOrderedHistoryItems(persistedTxHistory)) + const cursorRef = useRef(persistedCursor) + const previousSyncKeyRef = useRef(syncKey) + + useEffect(() => { + cursorRef.current = persistedCursor + }, [persistedCursor]) + + useEffect(() => { + dataRef.current = data + }, [data]) + + useEffect(() => { + if (previousSyncKeyRef.current !== syncKey) { + previousSyncKeyRef.current = syncKey + const nextOrderedHistory = getOrderedHistoryItems(persistedTxHistory) + orderedHistoryRef.current = nextOrderedHistory + const nextHistory = rebuildHistoryFromOrder(nextOrderedHistory) + dataRef.current = nextHistory + setData(nextHistory) + return + } + + if (!persistedTxHistory) { + return + } + + const nextOrderedHistory = mergePersistedHistorySnapshot(orderedHistoryRef.current, persistedTxHistory) + orderedHistoryRef.current = nextOrderedHistory + const nextHistory = rebuildHistoryFromOrder(nextOrderedHistory) + dataRef.current = nextHistory + setData(nextHistory) + }, [persistedTxHistory, syncKey]) + + useEffect(() => { + resetPolling() + }, [chainId, resetPolling, safeAddress]) + + useEffect(() => { + if (!safeAddress || !provider || !syncKey) { + setLoading(false) + setError(undefined) + dataRef.current = undefined + orderedHistoryRef.current = [] + setData(undefined) + return + } + + const safeContract = getSafeContract(safeAddress, safe.version, provider) + + if (!safeContract) { + setLoading(false) + return + } + + const executionSuccessFilter = safeContract.filters.ExecutionSuccess() + let cancelled = false + + const setOrderedHistory = (nextOrderedHistory: TxHistoryItem[]) => { + orderedHistoryRef.current = nextOrderedHistory + const nextHistory = rebuildHistoryFromOrder(nextOrderedHistory) + dataRef.current = nextHistory + setData(nextHistory) + } + + const updateCursor = (nextCursor: TxHistoryBackfillCursor) => { + const mergedCursor = mergeCursor(cursorRef.current, nextCursor) + cursorRef.current = mergedCursor + + dispatch( + setTxHistoryCursor({ + chainId, + safeAddress, + cursor: mergedCursor, + }), + ) + } + + const decodeTxData = (data: string): SafeTransactionData | undefined => { + try { + const decodedTxData = safeContract.interface.decodeFunctionData('execTransaction', data) + return parseDecodedTxData(decodedTxData, 0) + } catch { + return undefined + } + } + + const getSafeTxHash = (log: ExecutionSuccessLog): string | undefined => { + const argsTxHash = log.args?.txHash ?? log.args?.[0] + if (argsTxHash) { + return argsTxHash + } + + if (!log.topics || !log.data) { + return undefined + } + + try { + const parsed = safeContract.interface.parseLog({ topics: log.topics, data: log.data }) + return parsed.args.txHash ?? parsed.args[0] + } catch { + return undefined + } + } + + const retryUnavailableTxDetails = async () => { + const nextOrderedHistory = await Promise.all( + orderedHistoryRef.current.map(async (item) => { + if (!item.txDetailsUnavailable) { + return item + } + + const tx = await provider.getTransaction(item.txHash) + if (!tx) { + return item + } + + return { + ...item, + executor: tx.from, + decodedTxData: decodeTxData(tx.data), + txDetailsUnavailable: undefined, + } + }), + ) + + if (!isEqual(nextOrderedHistory, orderedHistoryRef.current)) { + setOrderedHistory(nextOrderedHistory) + } + } + + const parseLogs = async (logs: ExecutionSuccessLog[]): Promise => { + const parsed: Array = await Promise.all( + logs.map(async (log) => { + const safeTxHash = getSafeTxHash(log) + if (!safeTxHash) { + return undefined + } + + const [block, tx] = await Promise.all([ + provider.getBlock(log.blockNumber), + provider.getTransaction(log.transactionHash), + ]) + + return { + txId: buildMultisigTxId(safeAddress, safeTxHash), + txHash: log.transactionHash, + safeTxHash, + timestamp: (block?.timestamp ?? 0) * 1000, + executor: tx?.from ?? constants.AddressZero, + decodedTxData: tx ? decodeTxData(tx.data) : undefined, + txDetailsUnavailable: tx ? undefined : true, + } + }), + ) + + return parsed.filter((item): item is TxHistoryItem => isTxHistoryItem(item)) + } + + const syncLogsInRanges = async ({ + latestBlock, + stopAtBlock, + mode, + onRangeApplied, + }: { + latestBlock: number + stopAtBlock: number + mode: 'backfill' | 'forward' + onRangeApplied?: (range: BlockRange) => void + }) => { + if (latestBlock < stopAtBlock) { + return + } + + const baseInsertionIndex = mode === 'forward' ? orderedHistoryRef.current.length : 0 + let insertionIndex = baseInsertionIndex + let previousAppliedRange: BlockRange | undefined + + await queryFilterBackwards({ + latestBlock, + stopAtBlock, + batchSize, + maxConcurrentRequests, + queryRange: (range) => safeContract.queryFilter(executionSuccessFilter, range.fromBlock, range.toBlock), + onBatch: async (logs, range) => { + if (cancelled) { + return + } + + const parsed = await parseLogs(logs as ExecutionSuccessLog[]) + if (cancelled) { + return + } + + const isNewOlderGroup = previousAppliedRange !== undefined && range.toBlock < previousAppliedRange.fromBlock + + if (isNewOlderGroup) { + insertionIndex = baseInsertionIndex + } + + const insertionResult = insertRangeIntoOrderedHistory(orderedHistoryRef.current, parsed, insertionIndex) + insertionIndex = insertionResult.nextInsertionIndex + previousAppliedRange = range + + setOrderedHistory(insertionResult.orderedHistory) + onRangeApplied?.(range) + }, + }) + } + + const run = async () => { + setLoading(true) + setError(undefined) + + try { + const latestBlock = await provider.getBlockNumber() + if (cancelled) { + return + } + + await retryUnavailableTxDetails() + if (cancelled) { + return + } + + let activeCursor = cursorRef.current + + if (!activeCursor) { + activeCursor = createInitialCursor(latestBlock) + updateCursor(activeCursor) + } + + if (latestBlock > activeCursor.latestSyncedBlock) { + await syncLogsInRanges({ + latestBlock, + stopAtBlock: activeCursor.latestSyncedBlock + 1, + mode: 'forward', + }) + + if (cancelled) { + return + } + + updateCursor({ + ...(cursorRef.current ?? INITIAL_CURSOR), + latestSyncedBlock: latestBlock, + }) + } + + const backfillCursor = (cursorRef.current ?? activeCursor).backfillCursor + const backfillComplete = (cursorRef.current ?? activeCursor).backfillComplete + + if (!backfillComplete) { + await syncLogsInRanges({ + latestBlock: backfillCursor, + stopAtBlock: 0, + mode: 'backfill', + onRangeApplied: (range) => { + updateCursor(buildCursorAfterRange(cursorRef.current ?? activeCursor, latestBlock, range)) + }, + }) + } + } catch (err) { + if (cancelled) { + return + } + + setError(asError(err)) + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + void run() + + return () => { + cancelled = true + } + }, [batchSize, chainId, maxConcurrentRequests, pollCount, provider, safe.version, safeAddress, syncKey, dispatch]) + + return { + data, + error, + loading, + } +} diff --git a/src/hooks/loadables/useLoadTxHistory.ts b/src/hooks/loadables/useLoadTxHistory.ts index a70be0d2..1f092e92 100644 --- a/src/hooks/loadables/useLoadTxHistory.ts +++ b/src/hooks/loadables/useLoadTxHistory.ts @@ -1,104 +1,23 @@ import { useEffect } from 'react' -import useAsync, { type AsyncResult } from '../useAsync' + import { Errors, logError } from '@/services/exceptions' -import useSafeInfo from '../useSafeInfo' -import useIntervalCounter from '@/hooks/useIntervalCounter' -import { POLLING_INTERVAL } from '@/config/constants' -import { useMultiWeb3ReadOnly } from '@/hooks/wallets/web3' -import { useAppDispatch } from '@/store' import { showNotification } from '@/store/notificationsSlice' -import { getSafeContract } from '@/utils/safe-versions' -import type { SafeTransactionData } from '@safe-global/safe-core-sdk-types' -import type { Result } from 'ethers/lib/utils' -import { buildMultisigTxId } from '@/utils/tx-id' +import { useAppDispatch } from '@/store' -export type TxHistoryItem = { - txId: string - txHash: string - safeTxHash: string - timestamp: number - executor: string - decodedTxData?: SafeTransactionData -} +import { useTxHistoryLoader } from './txHistory/useTxHistoryLoader' +import type { TxHistory } from './txHistory/types' -export type TxHistory = { - [txId: string]: TxHistoryItem -} +import type { AsyncResult } from '../useAsync' -function parseDecodedTxData(decodedTxData: Result, nonce: number): SafeTransactionData { - const [to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver] = decodedTxData - return { - to, - value, - data, - operation, - safeTxGas, - baseGas, - gasPrice, - gasToken, - refundReceiver, - nonce, - } -} +export type { TxHistory, TxHistoryItem } from './txHistory/types' export const useLoadTxHistory = (): AsyncResult => { const dispatch = useAppDispatch() - const provider = useMultiWeb3ReadOnly() - const { safe, safeAddress } = useSafeInfo() - const { chainId } = safe - const [pollCount, resetPolling] = useIntervalCounter(POLLING_INTERVAL) - - const [data, error, loading] = useAsync( - async () => { - if (!safeAddress || !provider) return - - const safeContract = getSafeContract(safeAddress, safe.version, provider) + const { data, error, loading } = useTxHistoryLoader() - if (!safeContract) return - - const logs = await safeContract.queryFilter(safeContract.filters.ExecutionSuccess(), 0, 'latest') - - let txs = await Promise.all( - logs.map(async (log, i) => { - const [timestamp, { executor, decodedTxData }]: [ - number, - { executor: string; decodedTxData: Result | undefined }, - ] = await Promise.all([ - provider.getBlock(log.blockNumber).then((block) => block.timestamp * 1000), - provider.getTransaction(log.transactionHash).then((tx) => { - try { - const decodedTxData = safeContract.interface.decodeFunctionData('execTransaction', tx.data) - return { executor: tx.from, decodedTxData } - } catch (e) { - return { executor: tx.from, decodedTxData: undefined } - } - }), - ]) - - return { - txId: buildMultisigTxId(safeAddress, log.args.txHash), - txHash: log.transactionHash, - safeTxHash: log.args.txHash, - timestamp, - executor, - decodedTxData: decodedTxData ? parseDecodedTxData(decodedTxData, i) : undefined, - } - }), - ) - - return txs.reduce((acc, tx) => { - acc[tx.txId] = tx - return acc - }, {} as TxHistory) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [safeAddress, provider, pollCount], - false, - ) - - // Log errors useEffect(() => { if (!error) return + dispatch( showNotification({ message: @@ -111,11 +30,6 @@ export const useLoadTxHistory = (): AsyncResult => { logError(Errors._602, error.message) }, [error, dispatch]) - // Reset the counter when safe address/chainId changes - useEffect(() => { - resetPolling() - }, [resetPolling, safeAddress, chainId]) - return [data, error, loading] } diff --git a/src/hooks/useLoadableStores.ts b/src/hooks/useLoadableStores.ts index 90b67445..b64df362 100644 --- a/src/hooks/useLoadableStores.ts +++ b/src/hooks/useLoadableStores.ts @@ -1,7 +1,9 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { type Slice } from '@reduxjs/toolkit' import { useAppDispatch } from '@/store' import { type AsyncResult } from './useAsync' +import useSafeInfo from './useSafeInfo' +import { buildTxHistorySyncKey } from '@/store/historicalRpcSyncSlice' // Import all the loadable hooks import useLoadChains from './loadables/useLoadChains' @@ -22,28 +24,69 @@ import useLoadSpendingLimits from '@/hooks/loadables/useLoadSpendingLimits' import { collectiblesBalanceSlice } from '@/store/collectiblesBalancesSlice' // Dispatch into the corresponding store when the loadable is loaded -const useUpdateStore = (slice: Slice, useLoadHook: () => AsyncResult): void => { +const useUpdateStore = ( + slice: Slice, + useLoadHook: () => AsyncResult, + getExtraPayload?: (params: { data: unknown; error: Error | undefined; loading: boolean }) => Record, +): void => { const dispatch = useAppDispatch() const [data, error, loading] = useLoadHook() const setAction = slice.actions.set useEffect(() => { + const extraPayload = getExtraPayload?.({ data, error, loading }) ?? {} dispatch( setAction({ data, error: data ? undefined : error?.message, loading: loading && !data, + ...extraPayload, }), ) - }, [dispatch, setAction, data, error, loading]) + }, [dispatch, setAction, data, error, loading, getExtraPayload]) +} + +const useUpdateTxHistoryStore = (syncKey: string | undefined): void => { + const dispatch = useAppDispatch() + const [data, error, loading] = useLoadTxHistory() + const previousSyncKeyRef = useRef(syncKey) + + useEffect(() => { + const syncKeyChanged = previousSyncKeyRef.current !== syncKey + previousSyncKeyRef.current = syncKey + + if (syncKeyChanged) { + dispatch( + txHistorySlice.actions.set({ + data: undefined, + error: undefined, + loading: false, + syncKey, + }), + ) + return + } + + dispatch( + txHistorySlice.actions.set({ + data, + error: data ? undefined : error?.message, + loading: loading && !data, + syncKey, + }), + ) + }, [data, dispatch, error, loading, syncKey]) } const useLoadableStores = () => { + const { safe, safeAddress } = useSafeInfo() + const txHistorySyncKey = safeAddress ? buildTxHistorySyncKey(safe.chainId, safeAddress) : undefined + useUpdateStore(chainsSlice, useLoadChains) useUpdateStore(safeInfoSlice, useLoadSafeInfo) useUpdateStore(balancesSlice, useLoadBalances) useUpdateStore(collectiblesBalanceSlice, useLoadCollectiblesBalances) - useUpdateStore(txHistorySlice, useLoadTxHistory) + useUpdateTxHistoryStore(txHistorySyncKey) useUpdateStore(txQueueSlice, useLoadTxQueue) useUpdateStore(spendingLimitSlice, useLoadSpendingLimits) } diff --git a/src/hooks/useTxHistory.ts b/src/hooks/useTxHistory.ts index 7963717b..dfda50fc 100644 --- a/src/hooks/useTxHistory.ts +++ b/src/hooks/useTxHistory.ts @@ -12,6 +12,7 @@ import { } from '@/utils/transactions' import { type DetailedTransaction, isDetailedTransactionListItem } from '@/utils/transaction-guards' import { selectTxHistory } from '@/store/txHistorySlice' +import { isTxHistoryItem } from './loadables/txHistory/types' const useTxHistory = (): { data: Array @@ -35,6 +36,7 @@ const useTxHistory = (): { const results: Array = await Promise.all( Object.values(executedTransactions) + .filter(isTxHistoryItem) .map(async (executedTx) => { let txKey = getTxKeyFromTxId(executedTx.txId) if (!txKey) return diff --git a/src/services/tx/txEvents.ts b/src/services/tx/txEvents.ts index 9edaac2c..b32075b2 100644 --- a/src/services/tx/txEvents.ts +++ b/src/services/tx/txEvents.ts @@ -1,4 +1,4 @@ -import type { TxHistoryItem } from '@/hooks/loadables/useLoadTxHistory' +import type { TxHistoryItem } from '@/hooks/loadables/txHistory/types' import EventBus from '@/services/EventBus' import type { RequestId } from '@safe-global/safe-apps-sdk' diff --git a/src/store/__tests__/historicalRpcSyncSlice.test.ts b/src/store/__tests__/historicalRpcSyncSlice.test.ts new file mode 100644 index 00000000..12761669 --- /dev/null +++ b/src/store/__tests__/historicalRpcSyncSlice.test.ts @@ -0,0 +1,128 @@ +import { + buildTxHistorySyncKey, + clearTxHistoryCursor, + historicalRpcSyncSlice, + selectTxHistoryCursor, + setTxHistoryCursor, +} from '../historicalRpcSyncSlice' +import type { RootState } from '..' + +describe('historicalRpcSyncSlice', () => { + describe('buildTxHistorySyncKey', () => { + it('should normalize the safe address casing in the sync key', () => { + expect(buildTxHistorySyncKey('1', '0xAbCdEf1234567890ABCDef1234567890abCDef12')).toBe( + '1:0xabcdef1234567890abcdef1234567890abcdef12', + ) + }) + }) + + describe('setTxHistoryCursor', () => { + it('should store the cursor by chain and safe address', () => { + const state = historicalRpcSyncSlice.reducer( + undefined, + setTxHistoryCursor({ + chainId: '1', + safeAddress: '0xAbCdEf1234567890ABCDef1234567890abCDef12', + cursor: { + latestSyncedBlock: 123, + backfillCursor: 45, + backfillComplete: false, + }, + }), + ) + + expect(state).toEqual({ + txHistoryBySafe: { + '1:0xabcdef1234567890abcdef1234567890abcdef12': { + latestSyncedBlock: 123, + backfillCursor: 45, + backfillComplete: false, + }, + }, + }) + }) + + it('should keep cursor progress monotonic for the same chain and safe address', () => { + const state = historicalRpcSyncSlice.reducer( + historicalRpcSyncSlice.reducer( + undefined, + setTxHistoryCursor({ + chainId: '1', + safeAddress: '0xAbCdEf1234567890ABCDef1234567890abCDef12', + cursor: { + latestSyncedBlock: 123, + backfillCursor: 45, + backfillComplete: false, + }, + }), + ), + setTxHistoryCursor({ + chainId: '1', + safeAddress: '0xAbCdEf1234567890ABCDef1234567890abCDef12', + cursor: { + latestSyncedBlock: 120, + backfillCursor: 90, + backfillComplete: false, + }, + }), + ) + + expect(state.txHistoryBySafe['1:0xabcdef1234567890abcdef1234567890abcdef12']).toEqual({ + latestSyncedBlock: 123, + backfillCursor: 45, + backfillComplete: false, + }) + }) + }) + + describe('clearTxHistoryCursor', () => { + it('should remove the stored cursor for the chain and safe address', () => { + const populatedState = historicalRpcSyncSlice.reducer( + undefined, + setTxHistoryCursor({ + chainId: '1', + safeAddress: '0xAbCdEf1234567890ABCDef1234567890abCDef12', + cursor: { + latestSyncedBlock: 123, + backfillCursor: 45, + backfillComplete: false, + }, + }), + ) + + const state = historicalRpcSyncSlice.reducer( + populatedState, + clearTxHistoryCursor({ + chainId: '1', + safeAddress: '0xAbCdEf1234567890ABCDef1234567890abCDef12', + }), + ) + + expect(state).toEqual({ + txHistoryBySafe: {}, + }) + }) + }) + + describe('selectTxHistoryCursor', () => { + it('should return the stored cursor for the provided key', () => { + const state = { + historicalRpcSync: { + txHistoryBySafe: { + '1:0xabcdef1234567890abcdef1234567890abcdef12': { + latestSyncedBlock: 123, + backfillCursor: 45, + backfillComplete: false, + }, + }, + }, + } as unknown as RootState + + expect(selectTxHistoryCursor(state, '1:0xabcdef1234567890abcdef1234567890abcdef12')).toEqual({ + latestSyncedBlock: 123, + backfillCursor: 45, + backfillComplete: false, + }) + }) + }) +}) diff --git a/src/store/__tests__/index.test.ts b/src/store/__tests__/index.test.ts index b7b0f10d..ec91e941 100644 --- a/src/store/__tests__/index.test.ts +++ b/src/store/__tests__/index.test.ts @@ -1,133 +1,165 @@ -import { _hydrationReducer } from '@/store' +jest.mock('@/services/local-storage/local', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})) + +import local from '@/services/local-storage/local' +import { _hydrationReducer, getPersistedState } from '@/store' +import { txHistorySlice } from '../txHistorySlice' +import { historicalRpcSyncSlice } from '../historicalRpcSyncSlice' + +const mockedLocal = local as unknown as { + getItem: jest.Mock + setItem: jest.Mock + removeItem: jest.Mock +} describe('store', () => { - describe('hydrationReducer', () => { - it('should return a merged state', () => { - const persistedState = { - str1: 'str1', - obj1: { - key1: true, // Persisted value - }, - arr1: ['arr1', 'arr2'], // Persisted value - } - - const initialState = { - str1: 'str1', - str2: 'str2', // New property - obj1: { - key1: 'key1', - key2: 'key2', // New property - }, - arr1: ['arr1'], - } - - // @ts-expect-error demo state - const mergedState = _hydrationReducer(initialState, { - type: '@@HYDRATE', - payload: persistedState, - }) + beforeEach(() => { + mockedLocal.getItem.mockReset() + mockedLocal.setItem.mockReset() + mockedLocal.removeItem.mockReset() + }) - expect(mergedState).toStrictEqual({ - str1: 'str1', - str2: 'str2', - obj1: { - key1: true, - key2: 'key2', - }, - arr1: ['arr1', 'arr2'], + describe('hydrationReducer', () => { + it('should merge persisted txHistory and historicalRpcSync without losing initial defaults', () => { + const initialState = _hydrationReducer(undefined, { + type: '@@INIT', }) - }) - it('should not replace the intial state', () => { const persistedState = { - str1: 'str1', - obj1: { - key1: true, // Persisted value + [txHistorySlice.name]: { + loading: true, + error: 'stale error', + syncKey: '1:0x1111111111111111111111111111111111111111', + data: { + persistedTx: { + txId: 'persistedTx', + txHash: '0xpersisted', + safeTxHash: '0xpersisted', + timestamp: 2, + executor: '0x0000000000000000000000000000000000000002', + }, + }, }, - arr1: ['arr1', 'arr2', 'arr3'], // Persisted value - } - - const initialState = { - str1: 'str1', - str2: 'str2', // New property - obj1: { - key1: 'key1', - key2: 'key2', // New property + [historicalRpcSyncSlice.name]: { + txHistoryBySafe: { + '1:0x1111111111111111111111111111111111111111': { + backfillCursor: 5, + }, + }, }, - arr1: ['arr1'], } - // @ts-expect-error demo state const mergedState = _hydrationReducer(initialState, { type: '@@HYDRATE', payload: persistedState, }) - expect(mergedState).not.toStrictEqual({ - str1: 'str1', - obj1: { - key1: true, + expect(mergedState[txHistorySlice.name]).toStrictEqual({ + ...initialState[txHistorySlice.name], + loading: false, + error: undefined, + syncKey: '1:0x1111111111111111111111111111111111111111', + data: { + persistedTx: { + txId: 'persistedTx', + txHash: '0xpersisted', + safeTxHash: '0xpersisted', + timestamp: 2, + executor: '0x0000000000000000000000000000000000000002', + }, }, - arr1: ['arr1', 'arr2', 'arr3'], }) - }) - - it('should not wipe the initial state if no localStorage entry is present', () => { - const initialState = { - str1: 'str1', - str2: 'str2', - obj1: { - key1: 'key1', - key2: 'key2', - }, - arr1: ['arr1'], - } - // @ts-expect-error demo state - const mergedState = _hydrationReducer(initialState, { - type: '@@HYDRATE', - // No localStorage entry - payload: undefined, + expect( + mergedState[historicalRpcSyncSlice.name].txHistoryBySafe['1:0x1111111111111111111111111111111111111111'], + ).toEqual({ + latestSyncedBlock: 0, + backfillCursor: 5, + backfillComplete: false, }) - expect(mergedState).not.toBeUndefined() - - expect(mergedState).toStrictEqual({ - str1: 'str1', - str2: 'str2', - obj1: { - key1: 'key1', - key2: 'key2', - }, - arr1: ['arr1'], - }) + expect(mergedState.settings).toStrictEqual(initialState.settings) }) + }) - it('should return a new state, not mutating the initial or persisted state', () => { - const persistedState = { - str1: 'str1', - } - - const initialState = { - str1: 'str1', - str2: 'str2', - } - - // @ts-expect-error demo state - const mergedState = _hydrationReducer(initialState, { - type: '@@HYDRATE', - payload: persistedState, + describe('getPersistedState', () => { + it('should include persisted txHistory and historicalRpcSync slices', () => { + mockedLocal.getItem.mockImplementation((key: string) => { + if (key === txHistorySlice.name) { + return { + loading: false, + syncKey: '1:0x1111111111111111111111111111111111111111', + data: { + persistedTx: { + txId: 'persistedTx', + txHash: '0xpersisted', + safeTxHash: '0xpersisted', + timestamp: 2, + executor: '0x0000000000000000000000000000000000000002', + }, + }, + } + } + + if (key === historicalRpcSyncSlice.name) { + return { + txHistoryBySafe: { + '1:0x1111111111111111111111111111111111111111': { + backfillCursor: 5, + }, + }, + } + } + + return null }) - expect(mergedState).toStrictEqual({ - str1: 'str1', - str2: 'str2', + const persistedState = getPersistedState() + const expectedSliceNames = [ + 'session', + 'addressBook', + 'pendingTxs', + 'addedSafes', + 'settings', + 'safeApps', + 'pendingSafeMessages', + 'batch', + 'customChains', + 'customTokens', + 'customCollectibles', + 'addedTxs', + txHistorySlice.name, + historicalRpcSyncSlice.name, + ] + + expect(mockedLocal.getItem.mock.calls).toHaveLength(expectedSliceNames.length) + expect(mockedLocal.getItem.mock.calls.map(([key]) => key).sort()).toEqual([...expectedSliceNames].sort()) + expect(persistedState[txHistorySlice.name]).toStrictEqual({ + loading: false, + syncKey: '1:0x1111111111111111111111111111111111111111', + data: { + persistedTx: { + txId: 'persistedTx', + txHash: '0xpersisted', + safeTxHash: '0xpersisted', + timestamp: 2, + executor: '0x0000000000000000000000000000000000000002', + }, + }, + }) + expect(persistedState[historicalRpcSyncSlice.name]).toStrictEqual({ + txHistoryBySafe: { + '1:0x1111111111111111111111111111111111111111': { + backfillCursor: 5, + }, + }, }) - - // @ts-expect-error demo state - expect(mergedState === initialState).toBeFalsy() - // @ts-expect-error demo state - expect(mergedState === persistedState).toBeFalsy() }) }) }) diff --git a/src/store/__tests__/settingsSlice.test.ts b/src/store/__tests__/settingsSlice.test.ts index e9bf78c5..a4a9e748 100644 --- a/src/store/__tests__/settingsSlice.test.ts +++ b/src/store/__tests__/settingsSlice.test.ts @@ -1,6 +1,13 @@ +import { + HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE, + HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS, + getPositiveIntegerOrDefault, +} from '@/config/constants' import { settingsSlice, initialState, + selectHistoricalRpcLogBatchSize, + selectHistoricalRpcLogMaxConcurrentRequests, selectSafeAppsUseLightBackground, selectWalletConnectApiKey, selectWalletConnectPairingCode, @@ -104,6 +111,77 @@ describe('settingsSlice', () => { }) }) + describe('historical rpc log settings', () => { + it('should set the historical rpc log batch size', () => { + const state = settingsSlice.reducer(initialState, settingsSlice.actions.setHistoricalRpcLogBatchSize(2500)) + + expect(state.env.historicalRpcLogBatchSize).toBe(2500) + }) + + it('should set the historical rpc log max concurrent requests', () => { + const state = settingsSlice.reducer( + initialState, + settingsSlice.actions.setHistoricalRpcLogMaxConcurrentRequests(12), + ) + + expect(state.env.historicalRpcLogMaxConcurrentRequests).toBe(12) + }) + + it('falls back to defaults when persisted values are unusable', () => { + const state = { + [settingsSlice.name]: { + ...initialState, + env: { + ...initialState.env, + historicalRpcLogBatchSize: Number.NaN, + historicalRpcLogMaxConcurrentRequests: 0, + }, + }, + } as any + + expect(selectHistoricalRpcLogBatchSize(state)).toBe(HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE) + expect(selectHistoricalRpcLogMaxConcurrentRequests(state)).toBe(HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS) + }) + + it('falls back to defaults for stringified unusable persisted values', () => { + const state = { + [settingsSlice.name]: { + ...initialState, + env: { + ...initialState.env, + historicalRpcLogBatchSize: 'NaN', + historicalRpcLogMaxConcurrentRequests: '0', + }, + }, + } as any + + expect(selectHistoricalRpcLogBatchSize(state)).toBe(HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE) + expect(selectHistoricalRpcLogMaxConcurrentRequests(state)).toBe(HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS) + }) + + it('returns large persisted positive integers without clamping', () => { + const state = { + [settingsSlice.name]: { + ...initialState, + env: { + ...initialState.env, + historicalRpcLogBatchSize: 50000, + historicalRpcLogMaxConcurrentRequests: 99, + }, + }, + } as any + + expect(selectHistoricalRpcLogBatchSize(state)).toBe(50000) + expect(selectHistoricalRpcLogMaxConcurrentRequests(state)).toBe(99) + }) + }) + + describe('positive integer helper', () => { + it('returns the fallback for NaN', () => { + expect(getPositiveIntegerOrDefault(Number.NaN, 7)).toBe(7) + }) + }) + describe('safe apps appearance settings', () => { it('defaults safe apps background to light', () => { const state = { diff --git a/src/store/__tests__/txHistorySlice.test.ts b/src/store/__tests__/txHistorySlice.test.ts index 09aa2cac..844992ce 100644 --- a/src/store/__tests__/txHistorySlice.test.ts +++ b/src/store/__tests__/txHistorySlice.test.ts @@ -6,6 +6,30 @@ import { PendingStatus } from '../pendingTxsSlice' import type { RootState } from '..' describe('txHistorySlice', () => { + describe('set', () => { + it('should persist the tx history sync key with the payload', () => { + const state = txHistorySlice.reducer( + undefined, + txHistorySlice.actions.set({ + loading: false, + syncKey: '1:0xabc', + data: { + '0x123': { txId: '0x123', txHash: '0x456', safeTxHash: '0x123', timestamp: 0, executor: '0x789' }, + }, + }), + ) + + expect(state.syncKey).toBe('1:0xabc') + expect(state.data?.['0x123']).toEqual({ + txId: '0x123', + txHash: '0x456', + safeTxHash: '0x123', + timestamp: 0, + executor: '0x789', + }) + }) + }) + describe('selectTxFromHistory', () => { it('should match tx ids regardless of address casing', () => { const txIdLower = 'multisig_0xa710c854ede0eeaf84ea272363083cfa547dd552_0xabc' diff --git a/src/store/historicalRpcSyncSlice.ts b/src/store/historicalRpcSyncSlice.ts new file mode 100644 index 00000000..ccf101fd --- /dev/null +++ b/src/store/historicalRpcSyncSlice.ts @@ -0,0 +1,96 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' + +import type { RootState } from '@/store' + +export type TxHistoryBackfillCursor = { + latestSyncedBlock: number + backfillCursor: number + backfillComplete: boolean +} + +export type HistoricalRpcSyncState = { + txHistoryBySafe: Record +} + +const initialBackfillCursor: TxHistoryBackfillCursor = { + latestSyncedBlock: 0, + backfillCursor: 0, + backfillComplete: false, +} + +const mergeTxHistoryBackfillCursor = ( + current: TxHistoryBackfillCursor | undefined, + next: TxHistoryBackfillCursor, +): TxHistoryBackfillCursor => { + if (!current) { + return next + } + + const backfillComplete = current.backfillComplete || next.backfillComplete + + return { + latestSyncedBlock: Math.max(current.latestSyncedBlock, next.latestSyncedBlock), + backfillCursor: backfillComplete ? 0 : Math.min(current.backfillCursor, next.backfillCursor), + backfillComplete, + } +} + +export const buildTxHistorySyncKey = (chainId: string, safeAddress: string) => { + return `${chainId}:${safeAddress.toLowerCase()}` +} + +export const normalizeTxHistoryBackfillCursor = ( + cursor: Partial | undefined, +): TxHistoryBackfillCursor => { + return { + latestSyncedBlock: cursor?.latestSyncedBlock ?? initialBackfillCursor.latestSyncedBlock, + backfillCursor: cursor?.backfillCursor ?? initialBackfillCursor.backfillCursor, + backfillComplete: cursor?.backfillComplete ?? initialBackfillCursor.backfillComplete, + } +} + +export const normalizeHistoricalRpcSyncState = (state: HistoricalRpcSyncState | undefined): HistoricalRpcSyncState => { + return { + txHistoryBySafe: Object.fromEntries( + Object.entries(state?.txHistoryBySafe ?? {}).map(([key, cursor]) => [ + key, + normalizeTxHistoryBackfillCursor(cursor), + ]), + ), + } +} + +const initialState: HistoricalRpcSyncState = { + txHistoryBySafe: {}, +} + +type SetTxHistoryCursorPayload = { + chainId: string + safeAddress: string + cursor: TxHistoryBackfillCursor +} + +type ClearTxHistoryCursorPayload = { + chainId: string + safeAddress: string +} + +export const historicalRpcSyncSlice = createSlice({ + name: 'historicalRpcSync', + initialState, + reducers: { + setTxHistoryCursor: (state, { payload }: PayloadAction) => { + const key = buildTxHistorySyncKey(payload.chainId, payload.safeAddress) + state.txHistoryBySafe[key] = mergeTxHistoryBackfillCursor(state.txHistoryBySafe[key], payload.cursor) + }, + clearTxHistoryCursor: (state, { payload }: PayloadAction) => { + delete state.txHistoryBySafe[buildTxHistorySyncKey(payload.chainId, payload.safeAddress)] + }, + }, +}) + +export const { setTxHistoryCursor, clearTxHistoryCursor } = historicalRpcSyncSlice.actions + +export const selectTxHistoryCursor = (state: RootState, key: string) => { + return state[historicalRpcSyncSlice.name].txHistoryBySafe[key] +} diff --git a/src/store/index.ts b/src/store/index.ts index 11da0ea9..958bce92 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -16,6 +16,7 @@ import { safeInfoSlice } from './safeInfoSlice' import { balancesSlice } from './balancesSlice' import { sessionSlice } from './sessionSlice' import { txHistoryListener, txHistorySlice } from './txHistorySlice' +import { historicalRpcSyncSlice, normalizeHistoricalRpcSyncState } from './historicalRpcSyncSlice' import { txQueueSlice } from './txQueueSlice' import { addressBookSlice } from './addressBookSlice' import { notificationsSlice } from './notificationsSlice' @@ -41,6 +42,7 @@ const rootReducer = combineReducers({ [collectiblesBalanceSlice.name]: collectiblesBalanceSlice.reducer, [sessionSlice.name]: sessionSlice.reducer, [txHistorySlice.name]: txHistorySlice.reducer, + [historicalRpcSyncSlice.name]: historicalRpcSyncSlice.reducer, [txQueueSlice.name]: txQueueSlice.reducer, [addressBookSlice.name]: addressBookSlice.reducer, [notificationsSlice.name]: notificationsSlice.reducer, @@ -70,8 +72,23 @@ const persistedSlices: (keyof PreloadedState)[] = [ customTokensSlice.name, customCollectiblesSlice.name, addedTxsSlice.name, + txHistorySlice.name, + historicalRpcSyncSlice.name, ] +const normalizeTxHistoryHydration = (state: ReturnType | undefined) => { + const normalizedState = state ?? { + data: undefined, + loading: false, + } + + return { + ...normalizedState, + loading: false, + error: undefined, + } +} + export const getPersistedState = () => { return getPreloadedState(persistedSlices) } @@ -92,7 +109,13 @@ export const _hydrationReducer: typeof rootReducer = (state, action) => { * @see https://lodash.com/docs/4.17.15#merge */ - return merge({}, state, action.payload) + const mergedState = merge({}, state, action.payload) + + return { + ...mergedState, + [txHistorySlice.name]: normalizeTxHistoryHydration(mergedState[txHistorySlice.name]), + [historicalRpcSyncSlice.name]: normalizeHistoricalRpcSyncState(mergedState[historicalRpcSyncSlice.name]), + } } return rootReducer(state, action) } diff --git a/src/store/settingsSlice.ts b/src/store/settingsSlice.ts index c5dd52db..dc1f8e3c 100644 --- a/src/store/settingsSlice.ts +++ b/src/store/settingsSlice.ts @@ -3,7 +3,12 @@ import { createSelector, createSlice } from '@reduxjs/toolkit' import merge from 'lodash/merge' import type { RootState } from '@/store' -import { WC_PROJECT_ID } from '@/config/constants' +import { + HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE, + HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS, + WC_PROJECT_ID, + getPositiveIntegerOrDefault, +} from '@/config/constants' export type EnvState = { tenderly: { @@ -14,6 +19,8 @@ export type EnvState = { rpc: { [chainId: string]: string } + historicalRpcLogBatchSize: number + historicalRpcLogMaxConcurrentRequests: number ipfs: string walletConnectApiKey: string walletConnectPairingCode: string @@ -64,6 +71,8 @@ export const initialState: SettingsState = { }, env: { rpc: {}, + historicalRpcLogBatchSize: HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE, + historicalRpcLogMaxConcurrentRequests: HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS, tenderly: { orgName: '', projectName: '', @@ -131,6 +140,15 @@ export const settingsSlice = createSlice({ delete state.env.rpc[chainId] } }, + setHistoricalRpcLogBatchSize: (state, { payload }: PayloadAction) => { + state.env.historicalRpcLogBatchSize = payload + }, + setHistoricalRpcLogMaxConcurrentRequests: ( + state, + { payload }: PayloadAction, + ) => { + state.env.historicalRpcLogMaxConcurrentRequests = payload + }, setIPFS: (state, { payload }: PayloadAction) => { state.env.ipfs = payload }, @@ -165,6 +183,8 @@ export const { addCustomTokenList, removeCustomTokenList, setRpc, + setHistoricalRpcLogBatchSize, + setHistoricalRpcLogMaxConcurrentRequests, setIPFS, setTenderly, setWalletConnectApiKey, @@ -190,6 +210,17 @@ export const selectCustomTokenLists = createSelector( export const selectRpc = createSelector(selectSettings, (settings) => settings.env.rpc) +export const selectHistoricalRpcLogBatchSize = createSelector(selectSettings, (settings) => { + return getPositiveIntegerOrDefault(settings.env?.historicalRpcLogBatchSize, HISTORICAL_RPC_LOG_BLOCK_BATCH_SIZE) +}) + +export const selectHistoricalRpcLogMaxConcurrentRequests = createSelector(selectSettings, (settings) => { + return getPositiveIntegerOrDefault( + settings.env?.historicalRpcLogMaxConcurrentRequests, + HISTORICAL_RPC_LOG_MAX_CONCURRENT_REQUESTS, + ) +}) + export const selectIPFS = createSelector(selectSettings, (settings) => settings.env.ipfs) export const selectTenderly = createSelector(selectSettings, (settings) => settings.env.tenderly) diff --git a/src/store/txHistorySlice.ts b/src/store/txHistorySlice.ts index e8466d0e..2509236b 100644 --- a/src/store/txHistorySlice.ts +++ b/src/store/txHistorySlice.ts @@ -1,15 +1,33 @@ import type { listenerMiddlewareInstance, RootState } from '@/store' import { txDispatch, TxEvent } from '@/services/tx/txEvents' import { selectPendingTxs } from './pendingTxsSlice' -import { makeLoadableSlice } from './common' -import type { TxHistory, TxHistoryItem } from '@/hooks/loadables/useLoadTxHistory' -import { createSelector } from '@reduxjs/toolkit' +import type { Loadable } from './common' +import { isTxHistoryItem, type TxHistory, type TxHistoryItem } from '@/hooks/loadables/txHistory/types' +import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import { normalizeTxId } from '@/utils/tx-id' -const { slice, selector } = makeLoadableSlice('txHistory', undefined as TxHistory | undefined) +export type TxHistoryState = Loadable & { + syncKey?: string +} + +const initialState: TxHistoryState = { + data: undefined, + loading: false, +} + +export const txHistorySlice = createSlice({ + name: 'txHistory', + initialState, + reducers: { + set: (_, { payload }: PayloadAction) => ({ + ...payload, + data: payload.data ?? initialState.data, + syncKey: payload.syncKey, + }), + }, +}) -export const txHistorySlice = slice -export const selectTxHistory = selector +export const selectTxHistory = (state: RootState): TxHistoryState => state[txHistorySlice.name] export const selectTxFromHistory = createSelector( [selectTxHistory, (_: RootState, txId: string | undefined) => [txId]], @@ -40,7 +58,7 @@ export const txHistoryListener = (listenerMiddleware: typeof listenerMiddlewareI const pendingTxs = selectPendingTxs(listenerApi.getState()) for (const item of Object.values(action.payload.data)) { - if (!item?.txId) { + if (!isTxHistoryItem(item)) { continue } diff --git a/src/utils/__tests__/queryFilterBackfill.test.ts b/src/utils/__tests__/queryFilterBackfill.test.ts new file mode 100644 index 00000000..3637eab6 --- /dev/null +++ b/src/utils/__tests__/queryFilterBackfill.test.ts @@ -0,0 +1,85 @@ +import { getBackwardBlockRanges, queryFilterBackwards } from '../queryFilterBackfill' + +describe('getBackwardBlockRanges', () => { + it('builds backward ranges from latest to stop block', () => { + expect(getBackwardBlockRanges(25, 10, 0)).toEqual([ + { fromBlock: 16, toBlock: 25 }, + { fromBlock: 6, toBlock: 15 }, + { fromBlock: 0, toBlock: 5 }, + ]) + }) + + it('normalizes fractional and non-positive inputs safely', () => { + expect(getBackwardBlockRanges(3.9, 0.5, -1.2)).toEqual([ + { fromBlock: 3, toBlock: 3 }, + { fromBlock: 2, toBlock: 2 }, + { fromBlock: 1, toBlock: 1 }, + { fromBlock: 0, toBlock: 0 }, + ]) + }) +}) + +describe('queryFilterBackwards', () => { + it('applies batches in ascending order within a parallel group', async () => { + const applied: string[] = [] + + await queryFilterBackwards({ + latestBlock: 25, + stopAtBlock: 0, + batchSize: 10, + maxConcurrentRequests: 2, + queryRange: async ({ fromBlock, toBlock }) => [`${fromBlock}-${toBlock}`], + onBatch: async (logs) => { + applied.push(logs[0]) + }, + }) + + expect(applied).toEqual(['6-15', '16-25', '0-5']) + }) + + it('stops after the requested number of batches', async () => { + const queriedRanges: string[] = [] + const appliedRanges: string[] = [] + const collectedLogs: string[] = [] + + const result = await queryFilterBackwards({ + latestBlock: 49, + stopAtBlock: 0, + batchSize: 10, + maxConcurrentRequests: 3, + maxBatches: 2, + queryRange: async ({ fromBlock, toBlock }) => { + queriedRanges.push(`${fromBlock}-${toBlock}`) + return [`${fromBlock}-${toBlock}-a`, `${fromBlock}-${toBlock}-b`] + }, + onBatch: async (logs, range) => { + appliedRanges.push(`${range.fromBlock}-${range.toBlock}`) + collectedLogs.push(...logs) + }, + }) + + expect(queriedRanges).toEqual(['40-49', '30-39']) + expect(appliedRanges).toEqual(['30-39', '40-49']) + expect(collectedLogs).toEqual(['30-39-a', '30-39-b', '40-49-a', '40-49-b']) + expect(result).toEqual(collectedLogs) + }) + + it('normalizes non-positive concurrency and allows omitting onBatch', async () => { + const queriedRanges: string[] = [] + + const result = await queryFilterBackwards({ + latestBlock: 2, + stopAtBlock: 0, + batchSize: 0, + maxConcurrentRequests: 0, + maxBatches: 2, + queryRange: async ({ fromBlock, toBlock }) => { + queriedRanges.push(`${fromBlock}-${toBlock}`) + return [`${fromBlock}-${toBlock}`] + }, + }) + + expect(queriedRanges).toEqual(['2-2', '1-1']) + expect(result).toEqual(['2-2', '1-1']) + }) +}) diff --git a/src/utils/queryFilterBackfill.ts b/src/utils/queryFilterBackfill.ts new file mode 100644 index 00000000..05362d2e --- /dev/null +++ b/src/utils/queryFilterBackfill.ts @@ -0,0 +1,82 @@ +export type BlockRange = { + fromBlock: number + toBlock: number +} + +const positiveIntegerOrOne = (value: number) => { + return Math.max(1, Math.floor(Number.isFinite(value) ? value : 0)) +} + +const nonNegativeIntegerOrZero = (value: number) => { + return Math.max(0, Math.floor(Number.isFinite(value) ? value : 0)) +} + +export const getBackwardBlockRanges = (latestBlock: number, batchSize: number, stopAtBlock = 0): BlockRange[] => { + const normalizedBatchSize = positiveIntegerOrOne(batchSize) + const normalizedLatestBlock = nonNegativeIntegerOrZero(latestBlock) + const normalizedStopAtBlock = nonNegativeIntegerOrZero(stopAtBlock) + const ranges: BlockRange[] = [] + + for (let toBlock = normalizedLatestBlock; toBlock >= normalizedStopAtBlock; toBlock -= normalizedBatchSize) { + ranges.push({ + fromBlock: Math.max(normalizedStopAtBlock, toBlock - normalizedBatchSize + 1), + toBlock, + }) + } + + return ranges +} + +type QueryFilterBackwardsParams = { + latestBlock: number + batchSize: number + stopAtBlock?: number + maxConcurrentRequests: number + maxBatches?: number + queryRange: (range: BlockRange) => Promise + onBatch?: (logs: T[], range: BlockRange) => Promise | void +} + +export async function queryFilterBackwards({ + latestBlock, + batchSize, + stopAtBlock = 0, + maxConcurrentRequests, + maxBatches = Number.POSITIVE_INFINITY, + queryRange, + onBatch, +}: QueryFilterBackwardsParams): Promise { + const ranges = getBackwardBlockRanges(latestBlock, batchSize, stopAtBlock) + const normalizedMaxConcurrentRequests = positiveIntegerOrOne(maxConcurrentRequests) + const collectedLogs: T[] = [] + let processedBatches = 0 + + for ( + let index = 0; + index < ranges.length && processedBatches < maxBatches; + index += normalizedMaxConcurrentRequests + ) { + const remainingBatches = maxBatches - processedBatches + const group = ranges.slice(index, index + Math.min(normalizedMaxConcurrentRequests, remainingBatches)) + const results = await Promise.all( + group.map(async (range) => ({ + range, + logs: await queryRange(range), + })), + ) + + results.sort((a, b) => a.range.fromBlock - b.range.fromBlock) + + for (const result of results) { + if (processedBatches >= maxBatches) { + break + } + + await onBatch?.(result.logs, result.range) + processedBatches += 1 + collectedLogs.push(...result.logs) + } + } + + return collectedLogs +} diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index 11896714..a691c007 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -38,7 +38,7 @@ import { ethers } from 'ethers' import { type BaseTransaction } from '@safe-global/safe-apps-sdk' import { id } from 'ethers/lib/utils' import { isEmptyHexData } from '@/utils/hex' -import type { TxHistoryItem } from '@/hooks/loadables/useLoadTxHistory' +import type { TxHistoryItem } from '@/hooks/loadables/txHistory/types' import { addressEx } from '@/utils/addresses' export const makeTxFromDetails = (txDetails: TransactionDetails): Transaction => {