diff --git a/app/api/vaults/timelock-strategies/route.ts b/app/api/vaults/timelock-strategies/route.ts new file mode 100644 index 000000000..741b3c419 --- /dev/null +++ b/app/api/vaults/timelock-strategies/route.ts @@ -0,0 +1,4 @@ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export { GET, OPTIONS } from '@/server/vaults/timelock-strategies' diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/components/pages/vaults/components/detail/PendingTimelockStrategiesTable.test.tsx b/src/components/pages/vaults/components/detail/PendingTimelockStrategiesTable.test.tsx new file mode 100644 index 000000000..3a8400a8e --- /dev/null +++ b/src/components/pages/vaults/components/detail/PendingTimelockStrategiesTable.test.tsx @@ -0,0 +1,103 @@ +import type { TPendingTimelockStrategy } from '@pages/vaults/types/timelockStrategies' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { PendingTimelockStrategiesTable } from './PendingTimelockStrategiesTable' + +const pendingTimelock: TPendingTimelockStrategy = { + chainId: 1, + timelockAddress: '0x88Ba032be87d5EF1fbE87336B7090767F367BF73', + vaultAddress: '0x696d02Db93291651ED510704c9b286841d506987', + strategyAddress: '0x908244B6ef0e52911a380a5454aEC0743598Fb20', + operationId: '0x5dac358a2f25b7148ebb9bca035dc4739fae4092086f4e8f98cc201f7e773a98', + status: 'ready', + queuedAt: 1_780_000_000, + eta: 1_780_509_347, + delay: 604_800, + scheduleTxHash: '0xa6e8a54c3ff514951bca921cc38af55278980937816e5d04cd2d88fcf406199c', + executorLabel: 'yChad', + strategyName: 'Base Yearn Morpho OG USDC', + strategySymbol: 'ysUSDC', + maxDebtRaw: '100000000000000', + decodedCalls: [] +} + +function renderPendingTableHtml({ + defaultExpandedOperationId, + items +}: { + defaultExpandedOperationId?: `0x${string}` + items: TPendingTimelockStrategy[] +}): string { + globalThis.window = { + location: { + href: 'http://localhost/', + hostname: 'localhost' + } + } as Window & typeof globalThis + + return renderToStaticMarkup( + + ) +} + +describe('PendingTimelockStrategiesTable', () => { + it('renders pending-specific column headers', () => { + const html = renderPendingTableHtml({ items: [pendingTimelock] }) + + expect(html).toContain('Strategy') + expect(html).toContain('Status') + expect(html).toContain('Max debt') + expect(html).toContain('yearn--table-head-label-wrapper') + expect(html).toContain('yearn--table-head-label') + }) + + it('renders the pending strategy status and max debt', () => { + const html = renderPendingTableHtml({ items: [pendingTimelock] }) + + expect(html).toContain('Base Yearn Morpho OG USDC') + expect(html).toContain('Timelock ready') + expect(html).toContain('100,000,000 USDC') + expect(html).not.toContain('0x908244...98Fb20') + }) + + it('renders expanded timelock audit details', () => { + const html = renderPendingTableHtml({ + defaultExpandedOperationId: pendingTimelock.operationId, + items: [pendingTimelock] + }) + + expect(html).toContain('This strategy is scheduled to be added to the vault but is still in the timelock') + expect(html).toContain('Strategy address:') + expect(html).toContain('0x908244...98Fb20') + expect(html).toContain('Schedule tx:') + expect(html).toContain('Operation id:') + expect(html).toContain('yChad') + }) + + it('does not render zero-hash fallbacks for missing audit hashes', () => { + const html = renderPendingTableHtml({ + defaultExpandedOperationId: '0x0000000000000000000000000000000000000000000000000000000000000000', + items: [ + { + ...pendingTimelock, + operationId: '0x0000000000000000000000000000000000000000000000000000000000000000', + scheduleTxHash: '0x0000000000000000000000000000000000000000000000000000000000000000' + } + ] + }) + + expect(html).toContain('Unavailable') + expect(html).not.toContain('0x000000...000000') + }) + + it('renders nothing without pending rows', () => { + expect(renderPendingTableHtml({ items: [] })).toBe('') + }) +}) diff --git a/src/components/pages/vaults/components/detail/PendingTimelockStrategiesTable.tsx b/src/components/pages/vaults/components/detail/PendingTimelockStrategiesTable.tsx new file mode 100644 index 000000000..883397dba --- /dev/null +++ b/src/components/pages/vaults/components/detail/PendingTimelockStrategiesTable.tsx @@ -0,0 +1,298 @@ +import type { TPendingTimelockStrategy } from '@pages/vaults/types/timelockStrategies' +import { + formatTimelockEta, + formatTimelockMaxDebt, + getTimelockBadgeLabel +} from '@pages/vaults/utils/timelockStrategyDisplay' +import { TokenLogo } from '@shared/components/TokenLogo' +import { IconChevron } from '@shared/icons/IconChevron' +import { IconCopy } from '@shared/icons/IconCopy' +import { IconLinkOut } from '@shared/icons/IconLinkOut' +import type { TAddress } from '@shared/types' +import { cl, truncateHex } from '@shared/utils' +import { copyToClipboard } from '@shared/utils/helpers' +import { getNetwork } from '@shared/utils/wagmi/utils' +import Link from 'next/link' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { env } from '@/env' +import { STRATEGY_PANEL_ROW_DESKTOP_LAYOUT } from './strategiesLayout' + +const isValidHexValue = (value: string | undefined): value is `0x${string}` => + Boolean(value && /^0x[a-fA-F0-9]+$/.test(value) && !/^0x0+$/.test(value)) + +const truncateHash = (hash: `0x${string}`, size: number): string => { + if (hash.length <= size * 2 + 4) { + return hash + } + + return `0x${hash.slice(2, size + 2)}...${hash.slice(-size)}` +} + +function TimelockHashLink({ + blockExplorer, + hash, + kind, + label +}: { + blockExplorer: string | undefined + hash: `0x${string}` | undefined + kind: 'tx' + label?: string +}): ReactElement { + if (!isValidHexValue(hash) || !blockExplorer) { + return {'Unavailable'} + } + + return ( + + {label ?? truncateHash(hash, 6)} + + + ) +} + +function TimelockHashValue({ hash }: { hash: `0x${string}` | undefined }): ReactElement { + if (!isValidHexValue(hash)) { + return {'Unavailable'} + } + + return ( +
+ {truncateHash(hash, 6)} + +
+ ) +} + +function PendingTimelockStrategyRow({ + blockExplorer, + chainId, + defaultExpanded, + item, + tokenAddress, + tokenDecimals, + tokenSymbol +}: { + blockExplorer: string | undefined + chainId: number + defaultExpanded: boolean + item: TPendingTimelockStrategy + tokenAddress: TAddress + tokenDecimals: number + tokenSymbol: string +}): ReactElement { + const [isExpanded, setIsExpanded] = useState(defaultExpanded) + const strategyName = item.strategyName ?? `Strategy ${truncateHex(item.strategyAddress, 6)}` + const maxDebt = formatTimelockMaxDebt(item.maxDebtRaw, tokenDecimals, tokenSymbol) + + return ( +
+ + + {isExpanded ? ( +
+
+

+ { + 'This strategy is scheduled to be added to the vault but is still in the timelock. It is not currently allocated to by this vault.' + } +

+
+ {'Timelock:'} + + {'Yearn strategy timelock'} + + +
+
+ {'Strategy address:'} +
+ {truncateHex(item.strategyAddress, 6)} + +
+
+
+ {'Status:'} + {item.status === 'ready' ? 'Ready' : 'Queued'} +
+
+ {'Ready after:'} + {formatTimelockEta(item.eta)} +
+
+ {'Queued:'} + {formatTimelockEta(item.queuedAt)} +
+
+ {'Schedule tx:'} + +
+
+ {'Operation id:'} + +
+
+ {'Executor:'} + {item.executorLabel} +
+
+
+ ) : null} +
+ ) +} + +export function PendingTimelockStrategiesTable({ + chainId, + items, + tokenAddress, + tokenDecimals, + tokenSymbol, + defaultExpandedOperationId +}: { + chainId: number + items: TPendingTimelockStrategy[] + tokenAddress: TAddress + tokenDecimals: number + tokenSymbol: string + defaultExpandedOperationId?: `0x${string}` +}): ReactElement | null { + const blockExplorer = getNetwork(chainId)?.defaultBlockExplorer + + if (items.length === 0) { + return null + } + + return ( +
+
+

{'Pending Strategies'}

+
+
+ +
+ ) +} diff --git a/src/components/pages/vaults/components/detail/VaultStrategiesSection.tsx b/src/components/pages/vaults/components/detail/VaultStrategiesSection.tsx index 25957ada6..6231bf759 100644 --- a/src/components/pages/vaults/components/detail/VaultStrategiesSection.tsx +++ b/src/components/pages/vaults/components/detail/VaultStrategiesSection.tsx @@ -4,6 +4,7 @@ import { } from '@pages/vaults/components/detail/strategyDisplayFees' import { ALL_VAULTSV3_KINDS_KEYS } from '@pages/vaults/constants' import { + getVaultAddress, getVaultAPR, getVaultChainID, getVaultName, @@ -14,7 +15,9 @@ import { type TKongVaultInput, type TKongVaultStrategy } from '@pages/vaults/domain/kongVaultSelectors' +import { usePendingTimelockStrategies } from '@pages/vaults/hooks/usePendingTimelockStrategies' import { useQueryArguments } from '@pages/vaults/hooks/useVaultsQueryArgs' +import type { TPendingTimelockStrategy } from '@pages/vaults/types/timelockStrategies' import type { TAllocationChartData } from '@shared/components/AllocationChart' import { DARK_MODE_COLORS, LIGHT_MODE_COLORS, useDarkMode } from '@shared/components/AllocationChart' import { useYearn } from '@shared/contexts/useYearn' @@ -23,6 +26,7 @@ import type { TSortDirection } from '@shared/types' import { cl, formatTvlDisplay, toAddress, toBigInt, toNormalizedBN } from '@shared/utils' import type { ReactElement } from 'react' import { lazy, Suspense, useCallback, useMemo } from 'react' +import { PendingTimelockStrategiesTable } from './PendingTimelockStrategiesTable' import { formatStrategiesPercent } from './strategiesPercentFormat' import { VaultsListHead } from './VaultsListHead' import { VaultsListStrategy } from './VaultsListStrategy' @@ -43,10 +47,15 @@ export function VaultStrategiesSection({ currentVault }: { currentVault: TKongVa const vaultVersion = getVaultVersion(currentVault) const vaultVariant = vaultVersion?.startsWith('3') || vaultVersion?.startsWith('~3') ? 'v3' : 'v2' const chainId = getVaultChainID(currentVault) + const vaultAddress = getVaultAddress(currentVault) const token = getVaultToken(currentVault) const fees = getVaultAPR(currentVault).fees const strategies = getVaultStrategies(currentVault) const totalAssets = getVaultTVL(currentVault).totalAssets + const pendingTimelockStrategies = usePendingTimelockStrategies({ + chainId, + vaultAddress + }) const { sortDirection, sortBy, onChangeSortDirection, onChangeSortBy } = useQueryArguments({ defaultSortBy: 'allocationPercentage', defaultTypes: ALL_VAULTSV3_KINDS_KEYS, @@ -77,6 +86,14 @@ export function VaultStrategiesSection({ currentVault }: { currentVault: TKongVa }) }, [allVaults, fees, strategies, vaultVariant, vaults]) + const pendingRows = useMemo((): TPendingTimelockStrategy[] => { + const currentStrategyAddresses = new Set(mergedList.map((strategy) => toAddress(strategy.address))) + + return (pendingTimelockStrategies.data?.items ?? []).filter( + (item) => !currentStrategyAddresses.has(toAddress(item.strategyAddress)) + ) + }, [mergedList, pendingTimelockStrategies.data?.items]) + const allocatedRatio = mergedList.reduce((acc, strategy) => acc + (strategy.details?.debtRatio || 0), 0) const unallocatedPercentage = Math.max(0, 10000 - allocatedRatio) const allocatedDebt = mergedList.reduce((acc, strategy) => acc + toBigInt(strategy.details?.totalDebt || 0), 0n) @@ -146,7 +163,7 @@ export function VaultStrategiesSection({ currentVault }: { currentVault: TKongVa return [...activeStrategyData, unallocatedData].filter(Boolean) as TAllocationChartData[] }, [activeStrategyData, token.decimals, tokenPrice, unallocatedPercentage, unallocatedValue]) - const isVaultListEmpty = mergedList.length === 0 + const isVaultListEmpty = filteredVaultList.length === 0 && pendingRows.length === 0 const isFilteredVaultListEmpty = filteredVaultList.length === 0 return ( @@ -203,123 +220,134 @@ export function VaultStrategiesSection({ currentVault }: { currentVault: TKongVa
) : (
- { - onChangeSortBy(newSortBy as never) - onChangeSortDirection(newSortDirection) - }} - items={[ - { - label: 'Strategy', - value: 'name', - sortable: false - }, - { - label: 'Allocation %', - value: 'allocationPercentage', - sortable: true - }, - { - label: 'Amount', - value: 'totalDebt', - sortable: true - }, - { - label: 'APY', - value: 'netAPR', - sortable: true - } - ]} - /> - {sortedVaultsToDisplay - .filter( - (strategy) => - !( - strategy.status === 'unallocated' || - strategy.details?.totalDebt === '0' || - !strategy.details?.debtRatio - ) - ) - .map((strategy) => ( - 0 ? ( + <> + { + onChangeSortBy(newSortBy as never) + onChangeSortDirection(newSortDirection) + }} + items={[ + { + label: 'Strategy', + value: 'name', + sortable: false + }, + { + label: 'Allocation %', + value: 'allocationPercentage', + sortable: true + }, + { + label: 'Amount', + value: 'totalDebt', + sortable: true + }, + { + label: 'APY', + value: 'netAPR', + sortable: true + } + ]} /> - ))} - {unallocatedPercentage > 0 && unallocatedValue > 0n ? ( -
-
-
-
-
-
- - Unallocated - -
-
-
-

Allocation %

-

{formatStrategiesPercent(unallocatedPercentage / 100)}

-
-
-

Amount

-

- {formatTvlDisplay( - Number(toNormalizedBN(unallocatedValue, token.decimals).normalized) * tokenPrice - )} -

-
-
-

APY

-

-

+ {sortedVaultsToDisplay + .filter( + (strategy) => + !( + strategy.status === 'unallocated' || + strategy.details?.totalDebt === '0' || + !strategy.details?.debtRatio + ) + ) + .map((strategy) => ( + + ))} + {unallocatedPercentage > 0 && unallocatedValue > 0n ? ( +
+
+
+
+
+
+ + Unallocated + +
+
+
+

Allocation %

+

{formatStrategiesPercent(unallocatedPercentage / 100)}

+
+
+

Amount

+

+ {formatTvlDisplay( + Number(toNormalizedBN(unallocatedValue, token.decimals).normalized) * tokenPrice + )} +

+
+
+

APY

+

-

+
+
+
-
-
-
+ ) : null} + {sortedVaultsToDisplay + .filter( + (strategy) => + strategy.status === 'unallocated' || + strategy.details?.totalDebt === '0' || + !strategy.details?.debtRatio + ) + .map((strategy) => ( + + ))} + ) : null} - {sortedVaultsToDisplay - .filter( - (strategy) => - strategy.status === 'unallocated' || - strategy.details?.totalDebt === '0' || - !strategy.details?.debtRatio - ) - .map((strategy) => ( - - ))} +
)}
diff --git a/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts b/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts deleted file mode 100644 index fc9a6c07f..000000000 --- a/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { STRATEGY_PANEL_ROW_DESKTOP_LAYOUT } from './strategiesLayout' - -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' - ) - }) -}) diff --git a/src/components/pages/vaults/components/detail/VaultsListStrategy.test.tsx b/src/components/pages/vaults/components/detail/VaultsListStrategy.test.tsx new file mode 100644 index 000000000..88d256169 --- /dev/null +++ b/src/components/pages/vaults/components/detail/VaultsListStrategy.test.tsx @@ -0,0 +1,57 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { STRATEGY_PANEL_ROW_DESKTOP_LAYOUT } from './strategiesLayout' +import { VaultsListStrategy } from './VaultsListStrategy' + +function renderStrategyHtml(props?: Partial[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> { + const uniqueBlocks = [...new Set(logs.map((log) => log.blockNumber).filter((block): block is bigint => !!block))] + const blockEntries = await Promise.all( + uniqueBlocks.map(async (blockNumber) => { + const block = await client.getBlock({ blockNumber }) + return [blockNumber, Number(block.timestamp)] as const + }) + ) + + return new Map(blockEntries) +} + +function normalizeScheduledLogs(logs: TTimelockLog[], blockTimestamps: Map): TTimelockScheduledCall[] { + return logs.flatMap((log) => { + const args = log.args + + if ( + !args?.id || + args.index === undefined || + !args.target || + !args.data || + args.delay === undefined || + !log.transactionHash + ) { + return [] + } + + return [ + { + operationId: args.id, + index: Number(args.index), + target: args.target, + data: args.data, + delay: Number(args.delay), + blockTimestamp: log.blockNumber ? (blockTimestamps.get(log.blockNumber) ?? 0) : 0, + transactionHash: log.transactionHash + } + ] + }) +} + +const isScheduledLogForVault = (log: TTimelockLog, vaultAddress: `0x${string}`): boolean => + Boolean(log.args?.target && isAddressEqual(log.args.target, vaultAddress)) + +async function fetchOperationStatuses( + client: TTimelockPublicClient, + timelockAddress: `0x${string}`, + operationIds: `0x${string}`[] +): Promise> { + const entries = await Promise.all( + operationIds.map(async (id) => { + const [isPending, isReady, isDone, timestamp] = await Promise.all([ + client.readContract({ + address: timelockAddress, + abi: timelockControllerAbi, + functionName: 'isOperationPending', + args: [id] + }), + client.readContract({ + address: timelockAddress, + abi: timelockControllerAbi, + functionName: 'isOperationReady', + args: [id] + }), + client.readContract({ + address: timelockAddress, + abi: timelockControllerAbi, + functionName: 'isOperationDone', + args: [id] + }), + client.readContract({ + address: timelockAddress, + abi: timelockControllerAbi, + functionName: 'getTimestamp', + args: [id] + }) + ]) + + return [ + id, + { + isPending: Boolean(isPending), + isReady: Boolean(isReady), + isDone: Boolean(isDone), + timestamp: Number(timestamp) + } + ] as const + }) + ) + + return new Map(entries) +} + +async function readStringContractValue({ + address, + client, + functionName +}: { + address: `0x${string}` + client: TTimelockPublicClient + functionName: 'name' | 'symbol' +}): Promise { + try { + const value = await client.readContract({ + address, + abi: strategyMetadataAbi, + functionName + }) + + return typeof value === 'string' && value.trim() ? value : undefined + } catch { + return undefined + } +} + +async function fetchStrategyMetadata( + client: TTimelockPublicClient, + strategies: `0x${string}`[] +): Promise> { + const uniqueStrategies = [...new Map(strategies.map((strategy) => [strategy.toLowerCase(), strategy])).values()] + const entries = await Promise.all( + uniqueStrategies.map(async (address) => { + const [name, symbol] = await Promise.all([ + readStringContractValue({ address, client, functionName: 'name' }), + readStringContractValue({ address, client, functionName: 'symbol' }) + ]) + + return [address, { name, symbol }] as const + }) + ) + + return new Map(entries) +} + +const getCacheKey = (chainId: number, vaultAddress: `0x${string}`): string => `${chainId}:${vaultAddress.toLowerCase()}` + +export function clearTimelockStrategiesCache(): void { + cache.clear() +} + +export async function fetchPendingTimelockStrategies({ + chainId, + vaultAddress, + nowSeconds = Math.floor(Date.now() / 1000), + client +}: TFetchPendingTimelockStrategiesParams): Promise { + const controller = getTimelockStrategyController(chainId) + if (!controller) { + return [] + } + + const rpcUrl = resolveRpcUrl(chainId) + if (!rpcUrl && !client) { + console.warn( + `Missing RPC_URI_FOR_${chainId} or NEXT_PUBLIC_RPC_URI_FOR_${chainId}; pending timelock strategy lookup skipped.` + ) + return [] + } + + const cacheKey = getCacheKey(chainId, vaultAddress) + const cached = cache.get(cacheKey) + if (cached && cached.expiresAt > nowSeconds * 1000) { + return cached.items + } + + const publicClient = client ?? createTimelockClient(chainId, rpcUrl!) + const currentBlock = await publicClient.getBlockNumber() + const fromBlock = + currentBlock > controller.defaultLookbackBlocks ? currentBlock - controller.defaultLookbackBlocks : 0n + const ranges = buildBlockRanges(fromBlock, currentBlock) + const scheduledLogs = await fetchLogsByEvent({ + address: controller.timelockAddress, + client: publicClient, + event: CALL_SCHEDULED_EVENT, + ranges + }) + const sortedScheduledLogs = [...scheduledLogs] + .filter((log) => isScheduledLogForVault(log, vaultAddress)) + .sort((a, b) => logSortKey(a).localeCompare(logSortKey(b))) + const blockTimestamps = await resolveBlockTimestamps(publicClient, sortedScheduledLogs) + const scheduledCalls = normalizeScheduledLogs(sortedScheduledLogs, blockTimestamps) + const operationIds = [...new Set(scheduledCalls.map((call) => call.operationId))] + const operationStatuses = await fetchOperationStatuses(publicClient, controller.timelockAddress, operationIds) + const provisionalItems = decodePendingTimelockStrategies({ + controller, + vaultAddress, + scheduledCalls, + operationStatuses + }) + const strategyMetadata = await fetchStrategyMetadata( + publicClient, + provisionalItems.map((item) => item.strategyAddress) + ) + const items = decodePendingTimelockStrategies({ + controller, + vaultAddress, + scheduledCalls, + operationStatuses, + strategyMetadata + }) + + cache.set(cacheKey, { + expiresAt: nowSeconds * 1000 + CACHE_TTL_MS, + items + }) + + return items +} diff --git a/src/server/lib/timelockStrategies/types.ts b/src/server/lib/timelockStrategies/types.ts new file mode 100644 index 000000000..2ac6876df --- /dev/null +++ b/src/server/lib/timelockStrategies/types.ts @@ -0,0 +1,40 @@ +export type TTimelockStrategyStatus = 'queued' | 'ready' + +export type TPendingTimelockStrategy = { + chainId: number + timelockAddress: `0x${string}` + vaultAddress: `0x${string}` + strategyAddress: `0x${string}` + operationId: `0x${string}` + status: TTimelockStrategyStatus + 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[] +} + +export type TTimelockControllerConfig = { + chainId: number + timelockAddress: `0x${string}` + label: string + executorLabel: string + authorizedSafe?: `0x${string}` + minDelaySeconds: number + defaultLookbackBlocks: bigint +} diff --git a/src/server/vaults/timelock-strategies.test.ts b/src/server/vaults/timelock-strategies.test.ts new file mode 100644 index 000000000..450940393 --- /dev/null +++ b/src/server/vaults/timelock-strategies.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fetchPendingTimelockStrategiesMock = vi.fn() + +vi.mock('../lib/timelockStrategies/rpc', () => ({ + fetchPendingTimelockStrategies: fetchPendingTimelockStrategiesMock +})) + +function createRequest(search: string): Request { + return new Request(`https://yearn.fi/api/vaults/timelock-strategies${search}`) +} + +describe('vault timelock strategies route', () => { + beforeEach(() => { + vi.clearAllMocks() + fetchPendingTimelockStrategiesMock.mockResolvedValue([]) + }) + + it('validates chainId', async () => { + const { GET } = await import('./timelock-strategies') + const response = await GET(createRequest('?vault=0x696d02Db93291651ED510704c9b286841d506987')) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'chainId parameter required' }) + }) + + it('validates vault address', async () => { + const { GET } = await import('./timelock-strategies') + const response = await GET(createRequest('?chainId=1&vault=nope')) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'valid vault parameter required' }) + }) + + it('returns CORS headers for OPTIONS requests', async () => { + const { OPTIONS } = await import('./timelock-strategies') + const response = OPTIONS() + + expect(response.status).toBe(204) + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, OPTIONS') + }) + + it('returns empty items for unsupported chains', async () => { + const { GET } = await import('./timelock-strategies') + const response = await GET(createRequest('?chainId=250&vault=0x696d02Db93291651ED510704c9b286841d506987')) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + chainId: 250, + vaultAddress: '0x696d02Db93291651ED510704c9b286841d506987', + items: [] + }) + }) + + it('sets short cache headers on success', async () => { + const { GET } = await import('./timelock-strategies') + const response = await GET(createRequest('?chainId=1&vault=0x696d02Db93291651ED510704c9b286841d506987')) + + expect(response.status).toBe(200) + expect(response.headers.get('Vercel-CDN-Cache-Control')).toBe('public, s-maxage=60, stale-while-revalidate=30') + expect(response.headers.get('Cache-Control')).toBe('public, max-age=0, must-revalidate') + }) +}) diff --git a/src/server/vaults/timelock-strategies.ts b/src/server/vaults/timelock-strategies.ts new file mode 100644 index 000000000..1a3a87954 --- /dev/null +++ b/src/server/vaults/timelock-strategies.ts @@ -0,0 +1,62 @@ +import { GET_CORS_HEADERS, json, noContent, queryString } from '../http' +import { getVercelCdnCacheHeaders } from '../lib/cacheHeaders' +import { fetchPendingTimelockStrategies } from '../lib/timelockStrategies/rpc' +import type { TPendingTimelockStrategiesResponse } from '../lib/timelockStrategies/types' + +const TIMELOCK_STRATEGIES_CACHE_CONTROL = 'public, s-maxage=60, stale-while-revalidate=30' +const RESPONSE_HEADERS = { + ...GET_CORS_HEADERS, + ...getVercelCdnCacheHeaders(TIMELOCK_STRATEGIES_CACHE_CONTROL) +} +const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/ + +function parseChainId(value: string | undefined): number | null { + const parsed = value ? Number(value) : Number.NaN + + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +function parseVaultAddress(value: string | undefined): `0x${string}` | null { + return value && ADDRESS_PATTERN.test(value) ? (value as `0x${string}`) : null +} + +export async function buildVaultTimelockStrategiesResponse(params: { + chainId: number + vaultAddress: `0x${string}` +}): Promise { + const items = await fetchPendingTimelockStrategies({ + chainId: params.chainId, + vaultAddress: params.vaultAddress + }) + + return { + chainId: params.chainId, + vaultAddress: params.vaultAddress, + generatedAt: Math.floor(Date.now() / 1000), + items + } +} + +export function OPTIONS(): Response { + return noContent(GET_CORS_HEADERS) +} + +export async function GET(request: Request): Promise { + const chainId = parseChainId(queryString(request, 'chainId')) + if (chainId === null) { + return json({ error: 'chainId parameter required' }, { status: 400, headers: GET_CORS_HEADERS }) + } + + const vaultAddress = parseVaultAddress(queryString(request, 'vault')) + if (vaultAddress === null) { + return json({ error: 'valid vault parameter required' }, { status: 400, headers: GET_CORS_HEADERS }) + } + + const payload = await buildVaultTimelockStrategiesResponse({ chainId, vaultAddress }) + + return json(payload, { + headers: RESPONSE_HEADERS + }) +} + +export default GET