Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a11e43e
feat: add tx history query settings
devanoneth Apr 23, 2026
175bced
fix: tighten tx history settings defaults
devanoneth Apr 23, 2026
84d8463
feat: persist tx history backfill cursors
devanoneth Apr 23, 2026
25b7c55
fix: tighten tx history cursor tests
devanoneth Apr 23, 2026
71acc5f
fix: harden tx history cursor hydration
devanoneth Apr 24, 2026
262bcac
fix: strengthen tx history hydration tests
devanoneth Apr 24, 2026
514fabd
fix: assert persisted slice registration order
devanoneth Apr 24, 2026
2290857
fix: reset tx history transient hydration state
devanoneth Apr 24, 2026
3b50122
feat: add bounded parallel log backfill helper
devanoneth Apr 25, 2026
ee1fe88
feat: add bounded parallel log backfill helper
devanoneth Apr 25, 2026
48ad3f3
feat: add bounded parallel log backfill helper
devanoneth Apr 25, 2026
aa8b89a
feat: add bounded parallel log backfill helper
devanoneth Apr 25, 2026
a7e33cc
feat: add bounded parallel log backfill helper
devanoneth Apr 25, 2026
7e064fe
feat: load tx history with bounded resumable backfill
devanoneth Apr 25, 2026
4be9350
fix: tighten tx history backfill resume semantics
devanoneth Apr 25, 2026
2cb120a
fix: stabilize tx history execution ordering
devanoneth Apr 25, 2026
6d49642
fix: scope tx history persistence to sync key
devanoneth Apr 25, 2026
5d6e65a
fix: clear tx history on sync key transitions
devanoneth Apr 25, 2026
758bec2
feat: finalize tx history backfill ui
devanoneth Apr 26, 2026
6980be6
fix tx history ci checks
devanoneth Apr 27, 2026
fa5b286
fix null tx history entries
devanoneth Apr 27, 2026
579fdc1
handle missing tx history details
devanoneth Apr 27, 2026
23bdc28
harden execution success log parsing
devanoneth Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/components/settings/EnvironmentVariables/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<EnvironmentVariables />)
})

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(<EnvironmentVariables />)
})

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,
})
})
})
61 changes: 59 additions & 2 deletions src/components/settings/EnvironmentVariables/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -14,6 +26,8 @@ import { useEffect, useState } from 'react'

export enum EnvVariablesField {
rpc = 'rpc',
historicalRpcLogBatchSize = 'historicalRpcLogBatchSize',
historicalRpcLogMaxConcurrentRequests = 'historicalRpcLogMaxConcurrentRequests',
ipfs = 'ipfs',
tenderlyOrgName = 'tenderlyOrgName',
tenderlyProjectName = 'tenderlyProjectName',
Expand All @@ -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
Expand All @@ -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 ?? '',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -184,6 +209,38 @@ const EnvironmentVariables = () => {
fullWidth
/>

<Typography fontWeight={700} mb={2} mt={3}>
Chain queries
</Typography>

<Grid mt={2} container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
{...register(EnvVariablesField.historicalRpcLogBatchSize, {
valueAsNumber: true,
validate: (value) => Number.isInteger(value) && value > 0,
})}
variant="outlined"
label="Historical log block batch size"
type="number"
fullWidth
/>
</Grid>

<Grid item xs={12} md={6}>
<TextField
{...register(EnvVariablesField.historicalRpcLogMaxConcurrentRequests, {
valueAsNumber: true,
validate: (value) => Number.isInteger(value) && value > 0,
})}
variant="outlined"
label="Max parallel historical log requests"
type="number"
fullWidth
/>
</Grid>
</Grid>

<Typography fontWeight={700} mb={2} mt={3}>
IPFS URL
<Tooltip
Expand Down
58 changes: 58 additions & 0 deletions src/components/transactions/TxNavigation/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen } from '@/tests/test-utils'
import TxNavigation from '.'
import * as useSafeInfoHook from '@/hooks/useSafeInfo'

describe('TxNavigation', () => {
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(<TxNavigation />, {
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(<TxNavigation />, {
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()
})
})
32 changes: 31 additions & 1 deletion src/components/transactions/TxNavigation/index.tsx
Original file line number Diff line number Diff line change
@@ -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 <NavTabs tabs={transactionNavItems} />
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 (
<Box display="flex" alignItems="center" justifyContent="space-between" width="100%">
<NavTabs tabs={transactionNavItems} />

{isHistorySelected && cursor && !cursor.backfillComplete && (
<Typography variant="caption" color="text.secondary" textAlign="right" ml={2}>
{`Scanning history... block ${cursor.backfillCursor} of ${cursor.latestSyncedBlock}`}
</Typography>
)}

{isHistorySelected && cursor?.backfillComplete && (
<Typography variant="caption" color="text.secondary" textAlign="right" ml={2}>
{`History scanned. Latest block: ${cursor.latestSyncedBlock}`}
</Typography>
)}
</Box>
)
}

export default TxNavigation
13 changes: 13 additions & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
27 changes: 27 additions & 0 deletions src/hooks/__tests__/useLoadTxHistory.live.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading