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 => {