[0]>): string {
+ globalThis.window = {
+ location: {
+ href: 'http://localhost/',
+ hostname: 'localhost'
+ }
+ } as Window & typeof globalThis
+
+ return renderToStaticMarkup(
+
+ )
+}
+
+describe('VaultsListStrategy desktop layout', () => {
+ it('uses tightened strategy row desktop column classes with balanced desktop title wrapping', () => {
+ expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.nameColumnSpanClass).toBe('md:col-span-11')
+ expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valuesColumnSpanClass).toBe('md:col-span-12')
+ expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valuesGridClass).toBe('md:grid-cols-12 md:gap-2')
+ expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valueColumnSpanClass).toBe('md:col-span-4')
+ expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.nameLabelDesktopWrapClass).toBe(
+ 'md:[display:-webkit-box] md:[-webkit-box-orient:vertical] md:[-webkit-line-clamp:2] md:[text-wrap:balance] md:whitespace-normal'
+ )
+ })
+
+ it('keeps unallocated strategy rows in the existing placeholder state', () => {
+ const html = renderStrategyHtml()
+
+ expect(html).toContain('opacity-50')
+ expect(html).not.toContain('Timelock ready')
+ })
+})
diff --git a/src/components/pages/vaults/components/detail/VaultsListStrategy.tsx b/src/components/pages/vaults/components/detail/VaultsListStrategy.tsx
index c3ae6be40..7d9ac7f3e 100644
--- a/src/components/pages/vaults/components/detail/VaultsListStrategy.tsx
+++ b/src/components/pages/vaults/components/detail/VaultsListStrategy.tsx
@@ -92,6 +92,7 @@ export function VaultsListStrategy({
const allocationContent = isInactive || isUnallocated ? '-' : formatStrategiesPercent((details?.debtRatio || 0) / 100)
const amountContent = isInactive ? '-' : isUnallocated ? '-' : allocation
+ const blockExplorer = getNetwork(chainId)?.defaultBlockExplorer
return (
@@ -165,7 +166,7 @@ export function VaultsListStrategy({
className={`flex flex-col items-center md:items-end ${STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valueColumnSpanClass}`}
>
{'Amount'}
-
+
{amountContent}
@@ -234,7 +235,7 @@ export function VaultsListStrategy({
) : null}
event.stopPropagation()}
className={'flex items-center gap-1 text-text-secondary hover:text-text-primary'}
target={'_blank'}
diff --git a/src/components/pages/vaults/hooks/usePendingTimelockStrategies.test.tsx b/src/components/pages/vaults/hooks/usePendingTimelockStrategies.test.tsx
new file mode 100644
index 000000000..c796b2b45
--- /dev/null
+++ b/src/components/pages/vaults/hooks/usePendingTimelockStrategies.test.tsx
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest'
+import { buildPendingTimelockStrategiesUrl } from './usePendingTimelockStrategies'
+
+describe('buildPendingTimelockStrategiesUrl', () => {
+ it('builds the timelock strategies API URL', () => {
+ expect(
+ buildPendingTimelockStrategiesUrl({
+ chainId: 1,
+ vaultAddress: '0x696d02Db93291651ED510704c9b286841d506987'
+ })
+ ).toBe('/api/vaults/timelock-strategies?chainId=1&vault=0x696d02Db93291651ED510704c9b286841d506987')
+ })
+})
diff --git a/src/components/pages/vaults/hooks/usePendingTimelockStrategies.ts b/src/components/pages/vaults/hooks/usePendingTimelockStrategies.ts
new file mode 100644
index 000000000..b1994fdbd
--- /dev/null
+++ b/src/components/pages/vaults/hooks/usePendingTimelockStrategies.ts
@@ -0,0 +1,39 @@
+import type { TPendingTimelockStrategiesResponse } from '@pages/vaults/types/timelockStrategies'
+import { toAddress } from '@shared/utils'
+import { useQuery } from '@tanstack/react-query'
+
+export function buildPendingTimelockStrategiesUrl({
+ chainId,
+ vaultAddress
+}: {
+ chainId: number
+ vaultAddress: `0x${string}`
+}): string {
+ const params = new URLSearchParams({ chainId: String(chainId), vault: vaultAddress })
+ return `/api/vaults/timelock-strategies?${params}`
+}
+
+export function usePendingTimelockStrategies({
+ chainId,
+ vaultAddress,
+ enabled = true
+}: {
+ chainId: number
+ vaultAddress: `0x${string}`
+ enabled?: boolean
+}) {
+ return useQuery({
+ queryKey: ['pending-timelock-strategies', chainId, toAddress(vaultAddress)],
+ queryFn: async () => {
+ const response = await fetch(buildPendingTimelockStrategiesUrl({ chainId, vaultAddress }))
+ if (!response.ok) {
+ throw new Error('Failed to fetch pending timelock strategies')
+ }
+
+ return (await response.json()) as TPendingTimelockStrategiesResponse
+ },
+ enabled: enabled && Boolean(chainId && vaultAddress),
+ staleTime: 60_000,
+ refetchInterval: 60_000
+ })
+}
diff --git a/src/components/pages/vaults/types/timelockStrategies.ts b/src/components/pages/vaults/types/timelockStrategies.ts
new file mode 100644
index 000000000..caad084b8
--- /dev/null
+++ b/src/components/pages/vaults/types/timelockStrategies.ts
@@ -0,0 +1,28 @@
+export type TPendingTimelockStrategy = {
+ chainId: number
+ timelockAddress: `0x${string}`
+ vaultAddress: `0x${string}`
+ strategyAddress: `0x${string}`
+ operationId: `0x${string}`
+ status: 'queued' | 'ready'
+ queuedAt: number
+ eta: number
+ delay: number
+ scheduleTxHash: `0x${string}`
+ executorLabel: string
+ strategyName?: string
+ strategySymbol?: string
+ maxDebtRaw?: string
+ decodedCalls: Array<{
+ index: number
+ signature: string
+ target: `0x${string}`
+ }>
+}
+
+export type TPendingTimelockStrategiesResponse = {
+ chainId: number
+ vaultAddress: `0x${string}`
+ generatedAt: number
+ items: TPendingTimelockStrategy[]
+}
diff --git a/src/components/pages/vaults/utils/timelockStrategyDisplay.test.ts b/src/components/pages/vaults/utils/timelockStrategyDisplay.test.ts
new file mode 100644
index 000000000..f08259ee1
--- /dev/null
+++ b/src/components/pages/vaults/utils/timelockStrategyDisplay.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { formatTimelockEta, formatTimelockMaxDebt, getTimelockBadgeLabel } from './timelockStrategyDisplay'
+
+describe('timelock strategy display helpers', () => {
+ it('formats raw max debt with token decimals and symbol', () => {
+ expect(formatTimelockMaxDebt('100000000000000', 6, 'USDC')).toBe('100,000,000 USDC')
+ })
+
+ it('returns the ready badge label', () => {
+ expect(getTimelockBadgeLabel('ready')).toBe('Timelock ready')
+ })
+
+ it('returns the queued badge label', () => {
+ expect(getTimelockBadgeLabel('queued')).toBe('Pending timelock')
+ })
+
+ it('formats eta as a readable date', () => {
+ expect(formatTimelockEta(1_780_509_347, 1_780_000_000_000)).toContain('2026')
+ })
+})
diff --git a/src/components/pages/vaults/utils/timelockStrategyDisplay.ts b/src/components/pages/vaults/utils/timelockStrategyDisplay.ts
new file mode 100644
index 000000000..a873f41e3
--- /dev/null
+++ b/src/components/pages/vaults/utils/timelockStrategyDisplay.ts
@@ -0,0 +1,30 @@
+import { toNormalizedBN } from '@shared/utils'
+
+export function formatTimelockEta(etaSeconds: number, nowMs = Date.now()): string {
+ const etaMs = etaSeconds * 1000
+ const formatter = new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short'
+ })
+ const formattedDate = formatter.format(new Date(etaMs))
+
+ if (etaMs <= nowMs) {
+ return formattedDate
+ }
+
+ return formattedDate
+}
+
+export function formatTimelockMaxDebt(maxDebtRaw: string | undefined, decimals: number, tokenSymbol: string): string {
+ if (!maxDebtRaw) {
+ return '-'
+ }
+
+ const normalized = Number(toNormalizedBN(maxDebtRaw, decimals).normalized)
+
+ return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 6 }).format(normalized)} ${tokenSymbol}`
+}
+
+export function getTimelockBadgeLabel(status: 'queued' | 'ready'): string {
+ return status === 'ready' ? 'Timelock ready' : 'Pending timelock'
+}
diff --git a/src/server/lib/timelockStrategies/abi.ts b/src/server/lib/timelockStrategies/abi.ts
new file mode 100644
index 000000000..8cf2e8b38
--- /dev/null
+++ b/src/server/lib/timelockStrategies/abi.ts
@@ -0,0 +1,102 @@
+export const timelockControllerAbi = [
+ {
+ type: 'event',
+ name: 'CallScheduled',
+ inputs: [
+ { name: 'id', type: 'bytes32', indexed: true },
+ { name: 'index', type: 'uint256', indexed: true },
+ { name: 'target', type: 'address', indexed: false },
+ { name: 'value', type: 'uint256', indexed: false },
+ { name: 'data', type: 'bytes', indexed: false },
+ { name: 'predecessor', type: 'bytes32', indexed: false },
+ { name: 'delay', type: 'uint256', indexed: false }
+ ]
+ },
+ {
+ type: 'event',
+ name: 'CallExecuted',
+ inputs: [
+ { name: 'id', type: 'bytes32', indexed: true },
+ { name: 'index', type: 'uint256', indexed: true },
+ { name: 'target', type: 'address', indexed: false },
+ { name: 'value', type: 'uint256', indexed: false },
+ { name: 'data', type: 'bytes', indexed: false }
+ ]
+ },
+ {
+ type: 'event',
+ name: 'Cancelled',
+ inputs: [{ name: 'id', type: 'bytes32', indexed: true }]
+ },
+ {
+ type: 'function',
+ name: 'isOperationPending',
+ inputs: [{ name: 'id', type: 'bytes32' }],
+ outputs: [{ type: 'bool' }],
+ stateMutability: 'view'
+ },
+ {
+ type: 'function',
+ name: 'isOperationReady',
+ inputs: [{ name: 'id', type: 'bytes32' }],
+ outputs: [{ type: 'bool' }],
+ stateMutability: 'view'
+ },
+ {
+ type: 'function',
+ name: 'isOperationDone',
+ inputs: [{ name: 'id', type: 'bytes32' }],
+ outputs: [{ type: 'bool' }],
+ stateMutability: 'view'
+ },
+ {
+ type: 'function',
+ name: 'getTimestamp',
+ inputs: [{ name: 'id', type: 'bytes32' }],
+ outputs: [{ type: 'uint256' }],
+ stateMutability: 'view'
+ }
+] as const
+
+export const strategyManagementAbi = [
+ {
+ type: 'function',
+ name: 'add_strategy',
+ inputs: [{ name: 'new_strategy', type: 'address' }],
+ outputs: [],
+ stateMutability: 'nonpayable'
+ },
+ {
+ type: 'function',
+ name: 'update_max_debt_for_strategy',
+ inputs: [
+ { name: 'strategy', type: 'address' },
+ { name: 'new_max_debt', type: 'uint256' }
+ ],
+ outputs: [],
+ stateMutability: 'nonpayable'
+ },
+ {
+ type: 'function',
+ name: 'update_debt',
+ inputs: [
+ { name: 'strategy', type: 'address' },
+ { name: 'target_debt', type: 'uint256' }
+ ],
+ outputs: [{ type: 'uint256' }],
+ stateMutability: 'nonpayable'
+ },
+ {
+ type: 'function',
+ name: 'set_default_queue',
+ inputs: [{ name: 'new_default_queue', type: 'address[]' }],
+ outputs: [],
+ stateMutability: 'nonpayable'
+ }
+] as const
+
+export const strategyMetadataAbi = [
+ { type: 'function', name: 'name', inputs: [], outputs: [{ type: 'string' }], stateMutability: 'view' },
+ { type: 'function', name: 'symbol', inputs: [], outputs: [{ type: 'string' }], stateMutability: 'view' },
+ { type: 'function', name: 'asset', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' }
+] as const
diff --git a/src/server/lib/timelockStrategies/config.test.ts b/src/server/lib/timelockStrategies/config.test.ts
new file mode 100644
index 000000000..094d471a2
--- /dev/null
+++ b/src/server/lib/timelockStrategies/config.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+
+import { getTimelockStrategyController, TIMELOCK_ADDRESS } from './config'
+
+describe('timelock strategy config', () => {
+ it('resolves the mainnet timelock controller', () => {
+ expect(getTimelockStrategyController(1)?.timelockAddress).toBe(TIMELOCK_ADDRESS)
+ })
+
+ it('returns undefined for unsupported chains', () => {
+ expect(getTimelockStrategyController(250)).toBeUndefined()
+ })
+
+ it('labels the mainnet authorized safe as yChad', () => {
+ expect(getTimelockStrategyController(1)?.executorLabel).toBe('yChad')
+ })
+})
diff --git a/src/server/lib/timelockStrategies/config.ts b/src/server/lib/timelockStrategies/config.ts
new file mode 100644
index 000000000..eef16051a
--- /dev/null
+++ b/src/server/lib/timelockStrategies/config.ts
@@ -0,0 +1,59 @@
+import type { TTimelockControllerConfig } from './types'
+
+export const TIMELOCK_ADDRESS = '0x88Ba032be87d5EF1fbE87336B7090767F367BF73' as const
+
+export const TIMELOCK_STRATEGY_CONTROLLERS = [
+ {
+ chainId: 1,
+ timelockAddress: TIMELOCK_ADDRESS,
+ label: 'Yearn strategy timelock',
+ executorLabel: 'yChad',
+ authorizedSafe: '0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52',
+ minDelaySeconds: 604_800,
+ defaultLookbackBlocks: 216_000n
+ },
+ {
+ chainId: 10,
+ timelockAddress: TIMELOCK_ADDRESS,
+ label: 'Yearn strategy timelock',
+ executorLabel: 'Yearn Optimism timelock executor',
+ minDelaySeconds: 86_400,
+ defaultLookbackBlocks: 1_080_000n
+ },
+ {
+ chainId: 137,
+ timelockAddress: TIMELOCK_ADDRESS,
+ label: 'Yearn strategy timelock',
+ executorLabel: 'Yearn Polygon timelock executor',
+ minDelaySeconds: 604_800,
+ defaultLookbackBlocks: 1_080_000n
+ },
+ {
+ chainId: 8453,
+ timelockAddress: TIMELOCK_ADDRESS,
+ label: 'Yearn strategy timelock',
+ executorLabel: 'Yearn Base timelock executor',
+ minDelaySeconds: 604_800,
+ defaultLookbackBlocks: 1_080_000n
+ },
+ {
+ chainId: 42161,
+ timelockAddress: TIMELOCK_ADDRESS,
+ label: 'Yearn strategy timelock',
+ executorLabel: 'Yearn Arbitrum timelock executor',
+ minDelaySeconds: 604_800,
+ defaultLookbackBlocks: 1_080_000n
+ },
+ {
+ chainId: 747474,
+ timelockAddress: TIMELOCK_ADDRESS,
+ label: 'Yearn strategy timelock',
+ executorLabel: 'Yearn Katana timelock executor',
+ minDelaySeconds: 604_800,
+ defaultLookbackBlocks: 1_080_000n
+ }
+] as const satisfies readonly TTimelockControllerConfig[]
+
+export function getTimelockStrategyController(chainId: number): TTimelockControllerConfig | undefined {
+ return TIMELOCK_STRATEGY_CONTROLLERS.find((controller) => controller.chainId === chainId)
+}
diff --git a/src/server/lib/timelockStrategies/decode.test.ts b/src/server/lib/timelockStrategies/decode.test.ts
new file mode 100644
index 000000000..00001291d
--- /dev/null
+++ b/src/server/lib/timelockStrategies/decode.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest'
+
+import { getTimelockStrategyController } from './config'
+import { decodePendingTimelockStrategies, type TTimelockOperationStatus, type TTimelockScheduledCall } from './decode'
+
+const FORWARDED_OPERATION_ID = '0x5dac358a2f25b7148ebb9bca035dc4739fae4092086f4e8f98cc201f7e773a98'
+const USD_YVAULT = '0x696d02Db93291651ED510704c9b286841d506987'
+const OTHER_VAULT = '0x1111111111111111111111111111111111111111'
+const PENDING_STRATEGY = '0x908244B6ef0e52911a380a5454aEC0743598Fb20'
+const ADD_STRATEGY_DATA = '0xde7aeb41000000000000000000000000908244b6ef0e52911a380a5454aec0743598fb20'
+const UPDATE_MAX_DEBT_DATA =
+ '0xb9ddcd68000000000000000000000000908244b6ef0e52911a380a5454aec0743598fb2000000000000000000000000000000000000000000000000000005af3107a4000'
+const UNKNOWN_DATA = '0x12345678'
+const TX_HASH = '0xa6e8a54c3ff514951bca921cc38af55278980937816e5d04cd2d88fcf406199c'
+
+const controller = getTimelockStrategyController(1)
+
+function buildCalls(target: `0x${string}` = USD_YVAULT): TTimelockScheduledCall[] {
+ return [
+ {
+ operationId: FORWARDED_OPERATION_ID,
+ index: 0,
+ target,
+ data: ADD_STRATEGY_DATA,
+ delay: 604_800,
+ blockTimestamp: 1_780_000_000,
+ transactionHash: TX_HASH
+ },
+ {
+ operationId: FORWARDED_OPERATION_ID,
+ index: 1,
+ target,
+ data: UPDATE_MAX_DEBT_DATA,
+ delay: 604_800,
+ blockTimestamp: 1_780_000_000,
+ transactionHash: TX_HASH
+ },
+ {
+ operationId: FORWARDED_OPERATION_ID,
+ index: 2,
+ target,
+ data: UNKNOWN_DATA,
+ delay: 604_800,
+ blockTimestamp: 1_780_000_000,
+ transactionHash: TX_HASH
+ }
+ ]
+}
+
+const pendingStatus = new Map<`0x${string}`, TTimelockOperationStatus>([
+ [
+ FORWARDED_OPERATION_ID,
+ {
+ isPending: true,
+ isReady: true,
+ isDone: false,
+ timestamp: 1_780_509_347
+ }
+ ]
+])
+
+describe('decodePendingTimelockStrategies', () => {
+ it('decodes the forwarded operation into one pending strategy candidate', () => {
+ expect(controller).toBeDefined()
+ const [candidate] = decodePendingTimelockStrategies({
+ controller: controller!,
+ vaultAddress: USD_YVAULT,
+ scheduledCalls: buildCalls(),
+ operationStatuses: pendingStatus,
+ strategyMetadata: new Map([[PENDING_STRATEGY, { name: 'Base Yearn Morpho OG USDC', symbol: 'ysUSDC' }]])
+ })
+
+ expect(candidate.strategyAddress).toBe(PENDING_STRATEGY)
+ expect(candidate.status).toBe('ready')
+ expect(candidate.strategyName).toBe('Base Yearn Morpho OG USDC')
+ })
+
+ it('captures max debt from the companion call', () => {
+ const [candidate] = decodePendingTimelockStrategies({
+ controller: controller!,
+ vaultAddress: USD_YVAULT,
+ scheduledCalls: buildCalls(),
+ operationStatuses: pendingStatus
+ })
+
+ expect(candidate.maxDebtRaw).toBe('100000000000000')
+ })
+
+ it('filters out operations that are already done', () => {
+ const items = decodePendingTimelockStrategies({
+ controller: controller!,
+ vaultAddress: USD_YVAULT,
+ scheduledCalls: buildCalls(),
+ operationStatuses: new Map([
+ [FORWARDED_OPERATION_ID, { ...pendingStatus.get(FORWARDED_OPERATION_ID)!, isDone: true }]
+ ])
+ })
+
+ expect(items).toEqual([])
+ })
+
+ it('filters out operations that target a different vault', () => {
+ const items = decodePendingTimelockStrategies({
+ controller: controller!,
+ vaultAddress: USD_YVAULT,
+ scheduledCalls: buildCalls(OTHER_VAULT),
+ operationStatuses: pendingStatus
+ })
+
+ expect(items).toEqual([])
+ })
+
+ it('ignores unknown selectors without failing', () => {
+ const [candidate] = decodePendingTimelockStrategies({
+ controller: controller!,
+ vaultAddress: USD_YVAULT,
+ scheduledCalls: buildCalls(),
+ operationStatuses: pendingStatus
+ })
+
+ expect(candidate.decodedCalls.map((call) => call.signature)).toEqual([
+ 'add_strategy(address)',
+ 'update_max_debt_for_strategy(address,uint256)'
+ ])
+ })
+})
diff --git a/src/server/lib/timelockStrategies/decode.ts b/src/server/lib/timelockStrategies/decode.ts
new file mode 100644
index 000000000..1de840e87
--- /dev/null
+++ b/src/server/lib/timelockStrategies/decode.ts
@@ -0,0 +1,171 @@
+import { decodeFunctionData, isAddressEqual, toFunctionSignature } from 'viem'
+import { strategyManagementAbi } from './abi'
+import type { TPendingTimelockStrategy, TTimelockControllerConfig, TTimelockStrategyStatus } from './types'
+
+export type TTimelockScheduledCall = {
+ operationId: `0x${string}`
+ index: number
+ target: `0x${string}`
+ data: `0x${string}`
+ delay: number
+ blockTimestamp: number
+ transactionHash: `0x${string}`
+}
+
+export type TTimelockOperationStatus = {
+ isPending: boolean
+ isReady: boolean
+ isDone: boolean
+ timestamp: number
+}
+
+export type TDecodePendingTimelockStrategiesParams = {
+ controller: TTimelockControllerConfig
+ vaultAddress: `0x${string}`
+ scheduledCalls: TTimelockScheduledCall[]
+ operationStatuses: Map<`0x${string}`, TTimelockOperationStatus>
+ strategyMetadata?: Map<`0x${string}`, { name?: string; symbol?: string }>
+}
+
+type TDecodedManagementCall =
+ | {
+ index: number
+ operationId: `0x${string}`
+ target: `0x${string}`
+ name: 'add_strategy'
+ strategyAddress: `0x${string}`
+ signature: string
+ raw: TTimelockScheduledCall
+ }
+ | {
+ index: number
+ operationId: `0x${string}`
+ target: `0x${string}`
+ name: 'update_max_debt_for_strategy'
+ strategyAddress: `0x${string}`
+ maxDebtRaw: string
+ signature: string
+ raw: TTimelockScheduledCall
+ }
+ | {
+ index: number
+ operationId: `0x${string}`
+ target: `0x${string}`
+ name: 'update_debt' | 'set_default_queue'
+ signature: string
+ raw: TTimelockScheduledCall
+ }
+
+type TDecodedAddStrategyCall = Extract
+type TDecodedUpdateMaxDebtCall = Extract
+
+function decodeManagementCall(call: TTimelockScheduledCall): TDecodedManagementCall | null {
+ try {
+ const decoded = decodeFunctionData({ abi: strategyManagementAbi, data: call.data })
+ const abiItem = strategyManagementAbi.find((item) => item.type === 'function' && item.name === decoded.functionName)
+ const signature = abiItem ? toFunctionSignature(abiItem) : decoded.functionName
+
+ if (decoded.functionName === 'add_strategy') {
+ return {
+ index: call.index,
+ operationId: call.operationId,
+ target: call.target,
+ name: decoded.functionName,
+ strategyAddress: decoded.args[0],
+ signature,
+ raw: call
+ }
+ }
+
+ if (decoded.functionName === 'update_max_debt_for_strategy') {
+ return {
+ index: call.index,
+ operationId: call.operationId,
+ target: call.target,
+ name: decoded.functionName,
+ strategyAddress: decoded.args[0],
+ maxDebtRaw: decoded.args[1].toString(),
+ signature,
+ raw: call
+ }
+ }
+
+ return {
+ index: call.index,
+ operationId: call.operationId,
+ target: call.target,
+ name: decoded.functionName,
+ signature,
+ raw: call
+ }
+ } catch {
+ return null
+ }
+}
+
+const groupByOperationId = (calls: TTimelockScheduledCall[]): Map<`0x${string}`, TTimelockScheduledCall[]> =>
+ calls.reduce((groups, call) => {
+ const operationCalls = groups.get(call.operationId) ?? []
+ groups.set(call.operationId, [...operationCalls, call])
+ return groups
+ }, new Map<`0x${string}`, TTimelockScheduledCall[]>())
+
+export function decodePendingTimelockStrategies({
+ controller,
+ vaultAddress,
+ scheduledCalls,
+ operationStatuses,
+ strategyMetadata = new Map()
+}: TDecodePendingTimelockStrategiesParams): TPendingTimelockStrategy[] {
+ return [...groupByOperationId(scheduledCalls)]
+ .flatMap(([operationId, operationCalls]) => {
+ const liveStatus = operationStatuses.get(operationId)
+
+ if (!liveStatus?.isPending || liveStatus.isDone) {
+ return []
+ }
+
+ const decodedCalls = operationCalls
+ .map((call) => decodeManagementCall(call))
+ .filter((call): call is TDecodedManagementCall => call !== null)
+ .sort((a, b) => a.index - b.index)
+ const addStrategyCalls = decodedCalls.filter(
+ (call): call is TDecodedAddStrategyCall =>
+ call.name === 'add_strategy' && isAddressEqual(call.target, vaultAddress)
+ )
+
+ return addStrategyCalls.map((addCall) => {
+ const maxDebtCall = decodedCalls.find(
+ (call): call is TDecodedUpdateMaxDebtCall =>
+ call.name === 'update_max_debt_for_strategy' &&
+ isAddressEqual(call.target, vaultAddress) &&
+ isAddressEqual(call.strategyAddress, addCall.strategyAddress)
+ )
+ const metadata = strategyMetadata.get(addCall.strategyAddress)
+ const status: TTimelockStrategyStatus = liveStatus.isReady ? 'ready' : 'queued'
+
+ return {
+ chainId: controller.chainId,
+ timelockAddress: controller.timelockAddress,
+ vaultAddress,
+ strategyAddress: addCall.strategyAddress,
+ operationId,
+ status,
+ queuedAt: addCall.raw.blockTimestamp,
+ eta: liveStatus.timestamp,
+ delay: addCall.raw.delay,
+ scheduleTxHash: addCall.raw.transactionHash,
+ executorLabel: controller.executorLabel,
+ strategyName: metadata?.name,
+ strategySymbol: metadata?.symbol,
+ maxDebtRaw: maxDebtCall?.maxDebtRaw,
+ decodedCalls: decodedCalls.map((call) => ({
+ index: call.index,
+ signature: call.signature,
+ target: call.target
+ }))
+ }
+ })
+ })
+ .sort((a, b) => a.eta - b.eta)
+}
diff --git a/src/server/lib/timelockStrategies/rpc.test.ts b/src/server/lib/timelockStrategies/rpc.test.ts
new file mode 100644
index 000000000..f153d2d4c
--- /dev/null
+++ b/src/server/lib/timelockStrategies/rpc.test.ts
@@ -0,0 +1,135 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TIMELOCK_ADDRESS } from './config'
+import { clearTimelockStrategiesCache, fetchPendingTimelockStrategies } from './rpc'
+
+const OPERATION_ID = '0x5dac358a2f25b7148ebb9bca035dc4739fae4092086f4e8f98cc201f7e773a98'
+const VAULT = '0x696d02Db93291651ED510704c9b286841d506987'
+const OTHER_VAULT = '0x1111111111111111111111111111111111111111'
+const STRATEGY = '0x908244B6ef0e52911a380a5454aEC0743598Fb20'
+const ADD_STRATEGY_DATA = '0xde7aeb41000000000000000000000000908244b6ef0e52911a380a5454aec0743598fb20'
+const UPDATE_MAX_DEBT_DATA =
+ '0xb9ddcd68000000000000000000000000908244b6ef0e52911a380a5454aec0743598fb2000000000000000000000000000000000000000000000000000005af3107a4000'
+const TX_HASH = '0xa6e8a54c3ff514951bca921cc38af55278980937816e5d04cd2d88fcf406199c'
+
+function createMockClient(): any {
+ return {
+ getBlockNumber: vi.fn().mockResolvedValue(251_900_00n),
+ getBlock: vi.fn().mockResolvedValue({ timestamp: 1_780_000_000n }),
+ getLogs: vi.fn(({ event }) => {
+ if ((event as { name?: string }).name !== 'CallScheduled') {
+ return Promise.resolve([])
+ }
+
+ return Promise.resolve([
+ {
+ args: {
+ id: OPERATION_ID,
+ index: 0n,
+ target: VAULT,
+ data: ADD_STRATEGY_DATA,
+ delay: 604_800n
+ },
+ blockNumber: 251_882_99n,
+ logIndex: 10,
+ transactionHash: TX_HASH
+ },
+ {
+ args: {
+ id: OPERATION_ID,
+ index: 2n,
+ target: OTHER_VAULT,
+ data: ADD_STRATEGY_DATA,
+ delay: 604_800n
+ },
+ blockNumber: 251_883_00n,
+ logIndex: 12,
+ transactionHash: TX_HASH
+ },
+ {
+ args: {
+ id: OPERATION_ID,
+ index: 1n,
+ target: VAULT,
+ data: UPDATE_MAX_DEBT_DATA,
+ delay: 604_800n
+ },
+ blockNumber: 251_882_99n,
+ logIndex: 11,
+ transactionHash: TX_HASH
+ }
+ ])
+ }),
+ readContract: vi.fn(({ functionName }) => {
+ const responses: Record = {
+ isOperationPending: true,
+ isOperationReady: true,
+ isOperationDone: false,
+ getTimestamp: 1_780_509_347n,
+ name: 'Base Yearn Morpho OG USDC',
+ symbol: 'ysUSDC'
+ }
+
+ return Promise.resolve(responses[functionName])
+ })
+ }
+}
+
+describe('fetchPendingTimelockStrategies', () => {
+ beforeEach(() => {
+ clearTimelockStrategiesCache()
+ vi.unstubAllEnvs()
+ vi.restoreAllMocks()
+ })
+
+ it('fetches logs in bounded chunks and returns decoded pending strategies', async () => {
+ const client = createMockClient()
+
+ const items = await fetchPendingTimelockStrategies({ chainId: 1, vaultAddress: VAULT, client, nowSeconds: 1 })
+
+ expect(items[0]).toMatchObject({
+ strategyAddress: STRATEGY,
+ strategyName: 'Base Yearn Morpho OG USDC',
+ status: 'ready',
+ maxDebtRaw: '100000000000000'
+ })
+ expect(client.getLogs).toHaveBeenCalledWith(
+ expect.objectContaining({
+ address: TIMELOCK_ADDRESS,
+ fromBlock: expect.any(BigInt),
+ toBlock: expect.any(BigInt)
+ })
+ )
+ expect(client.getLogs.mock.calls.length).toBeGreaterThan(3)
+ expect(new Set(client.getLogs.mock.calls.map(([params]: any[]) => params.event.name))).toEqual(
+ new Set(['CallScheduled'])
+ )
+ expect(client.getBlock).toHaveBeenCalledTimes(1)
+ expect(client.getBlock).toHaveBeenCalledWith({ blockNumber: 251_882_99n })
+ })
+
+ it('uses the request cache for repeat lookups', async () => {
+ const client = createMockClient()
+
+ await fetchPendingTimelockStrategies({ chainId: 1, vaultAddress: VAULT, client, nowSeconds: 1 })
+ await fetchPendingTimelockStrategies({ chainId: 1, vaultAddress: VAULT, client, nowSeconds: 2 })
+
+ expect(client.getBlockNumber).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns empty results when RPC is missing', async () => {
+ vi.stubEnv('RPC_URI_FOR_1', '')
+ vi.stubEnv('NEXT_PUBLIC_RPC_URI_FOR_1', '')
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+
+ await expect(fetchPendingTimelockStrategies({ chainId: 1, vaultAddress: VAULT })).resolves.toEqual([])
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Missing RPC_URI_FOR_1 or NEXT_PUBLIC_RPC_URI_FOR_1; pending timelock strategy lookup skipped.'
+ )
+ })
+
+ it('returns empty results for unsupported chains', async () => {
+ await expect(
+ fetchPendingTimelockStrategies({ chainId: 250, vaultAddress: VAULT, client: createMockClient() })
+ ).resolves.toEqual([])
+ })
+})
diff --git a/src/server/lib/timelockStrategies/rpc.ts b/src/server/lib/timelockStrategies/rpc.ts
new file mode 100644
index 000000000..873449483
--- /dev/null
+++ b/src/server/lib/timelockStrategies/rpc.ts
@@ -0,0 +1,337 @@
+import { type Chain, createPublicClient, http, isAddressEqual } from 'viem'
+import { arbitrum, base, mainnet, optimism, polygon } from 'viem/chains'
+import { strategyMetadataAbi, timelockControllerAbi } from './abi'
+import { getTimelockStrategyController } from './config'
+import { decodePendingTimelockStrategies, type TTimelockOperationStatus, type TTimelockScheduledCall } from './decode'
+import type { TPendingTimelockStrategy } from './types'
+
+const MAX_LOG_RANGE_BLOCKS = 45_000n
+const CACHE_TTL_MS = 60_000
+
+type TTimelockPublicClient = {
+ getBlockNumber: () => Promise
+ getBlock: (params: { blockNumber: bigint }) => Promise<{ timestamp: bigint }>
+ getLogs: (params: {
+ address: `0x${string}`
+ event: unknown
+ fromBlock: bigint
+ toBlock: bigint
+ }) => Promise
+ readContract: (params: {
+ address: `0x${string}`
+ abi: unknown
+ functionName: string
+ args?: readonly unknown[]
+ }) => Promise
+}
+
+type TCacheEntry = {
+ expiresAt: number
+ items: TPendingTimelockStrategy[]
+}
+
+type TTimelockLogArgs = {
+ id?: `0x${string}`
+ index?: bigint
+ target?: `0x${string}`
+ data?: `0x${string}`
+ delay?: bigint
+}
+
+type TTimelockLog = {
+ args?: TTimelockLogArgs
+ blockNumber?: bigint | null
+ logIndex?: number | null
+ transactionHash?: `0x${string}` | null
+}
+
+type TFetchPendingTimelockStrategiesParams = {
+ chainId: number
+ vaultAddress: `0x${string}`
+ nowSeconds?: number
+ client?: TTimelockPublicClient
+}
+
+const cache = new Map()
+
+const KATANA_CHAIN = {
+ id: 747474,
+ name: 'Katana',
+ nativeCurrency: { decimals: 18, name: 'Ether', symbol: 'ETH' },
+ rpcUrls: { default: { http: [''] } }
+} as const satisfies Chain
+
+const CHAINS_BY_ID = new Map([
+ [1, mainnet],
+ [10, optimism],
+ [137, polygon],
+ [8453, base],
+ [42161, arbitrum],
+ [747474, KATANA_CHAIN]
+])
+
+const CALL_SCHEDULED_EVENT = timelockControllerAbi[0]
+
+const resolveRpcUrl = (chainId: number): string | undefined =>
+ (process.env[`RPC_URI_FOR_${chainId}`] || process.env[`NEXT_PUBLIC_RPC_URI_FOR_${chainId}`])?.trim()
+
+function createTimelockClient(chainId: number, rpcUrl: string): TTimelockPublicClient {
+ return createPublicClient({
+ chain: CHAINS_BY_ID.get(chainId),
+ transport: http(rpcUrl)
+ }) as TTimelockPublicClient
+}
+
+function buildBlockRanges(fromBlock: bigint, toBlock: bigint): Array<{ fromBlock: bigint; toBlock: bigint }> {
+ const rangeSize = toBlock >= fromBlock ? toBlock - fromBlock + 1n : 0n
+ const chunkCount = Number((rangeSize + MAX_LOG_RANGE_BLOCKS - 1n) / MAX_LOG_RANGE_BLOCKS)
+
+ return Array.from({ length: chunkCount }, (_value, index) => {
+ const rangeStart = fromBlock + BigInt(index) * MAX_LOG_RANGE_BLOCKS
+ const rangeEndCandidate = rangeStart + MAX_LOG_RANGE_BLOCKS - 1n
+
+ return {
+ fromBlock: rangeStart,
+ toBlock: rangeEndCandidate < toBlock ? rangeEndCandidate : toBlock
+ }
+ })
+}
+
+const logSortKey = (log: TTimelockLog): string => `${log.blockNumber ?? 0n}:${log.logIndex ?? 0}`
+
+async function fetchLogsByEvent({
+ address,
+ client,
+ event,
+ ranges
+}: {
+ address: `0x${string}`
+ client: TTimelockPublicClient
+ event: unknown
+ ranges: Array<{ fromBlock: bigint; toBlock: bigint }>
+}): Promise {
+ const logsByRange = await Promise.all(
+ ranges.map((range) =>
+ client.getLogs({
+ address,
+ event,
+ fromBlock: range.fromBlock,
+ toBlock: range.toBlock
+ })
+ )
+ )
+
+ return logsByRange.flat()
+}
+
+async function resolveBlockTimestamps(
+ client: TTimelockPublicClient,
+ logs: TTimelockLog[]
+): Promise