diff --git a/.env.example b/.env.example index b364548b3..73ef91c18 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,10 @@ VITE_PLAUSIBLE_TRACK_LOCALHOST= # Enso routing: set to 'true' to disable Enso zaps, forcing direct deposit/withdraw only VITE_ENSO_DISABLED= + +# Optimization API (migrated from optimization-visualizer) +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_USERNAME= +UPSTASH_REDIS_REST_TOKEN= +ENVIO_GRAPHQL_URL= +ENVIO_PASSWORD= diff --git a/api/optimization/_lib/assetLogos.ts b/api/optimization/_lib/assetLogos.ts new file mode 100644 index 000000000..3fbf1cf03 --- /dev/null +++ b/api/optimization/_lib/assetLogos.ts @@ -0,0 +1,237 @@ +interface VaultAssetToken { + chainId: number + tokenAddress: string +} + +const VAULT_ASSET_TOKENS: Record = { + '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204': { + chainId: 1, + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + }, + '0x028ec7330ff87667b6dfb0d94b954c820195336c': { + chainId: 1, + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F' + }, + '0x310b7ea7475a0b449cfd73be81522f1b88efafaa': { + chainId: 1, + tokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7' + }, + '0x182863131f9a4630ff9e27830d945b1413e347e8': { + chainId: 1, + tokenAddress: '0xdC035D45d973E3EC169d2276DDab16f1e407384F' + }, + '0xbf319ddc2edc1eb6fdf9910e39b37be221c8805f': { + chainId: 1, + tokenAddress: '0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E' + }, + '0xc56413869c6cdf96496f2b1ef801fedbdfa7ddb0': { + chainId: 1, + tokenAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + }, + '0xac37729b76db6438ce62042ae1270ee574ca7571': { + chainId: 1, + tokenAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + }, + '0x751f0cc6115410a3ee9ec92d08f46ff6da98b708': { + chainId: 1, + tokenAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' + }, + '0xae7d8db82480e6d8e3873ecbf22cf17b3d8a7308': { + chainId: 1, + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + }, + '0x92545bce636e6ee91d88d2d017182cd0bd2fc22e': { + chainId: 1, + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F' + }, + '0x9f4330700a36b29952869fac9b33f45eedd8a3d8': { + chainId: 1, + tokenAddress: '0x6440f144b7e50d6a8439336510312d2f54beb01d' + }, + '0x89e93172aef8261db8437b90c3dcb61545a05317': { + chainId: 1, + tokenAddress: '0x9cf12ccd6020b6888e4d4c4e4c7aca33c1eb91f8' + }, + '0x1f6f16945e395593d8050d6cc33e4328a515b648': { + chainId: 1, + tokenAddress: '0x22222222aea0076fca927a3f44dc0b4fdf9479d6' + }, + '0xaf71a4f5d93fb88b24c67760bcf9688a6c3a54d4': { + chainId: 1, + tokenAddress: '0x56072c95faa701256059aa122697b133aded9279' + }, + '0xb13cf163d916917d9cd6e836905ca5f12a1def4b': { + chainId: 8453, + tokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54BDA02913' + }, + '0xc3bd0a2193c8f027b82dde3611d18589ef3f62a9': { + chainId: 8453, + tokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54BDA02913' + }, + '0x4d81c7d534d703e0a0aecadf668c0e0253e1f1c3': { + chainId: 8453, + tokenAddress: '0x4200000000000000000000000000000000000006' + }, + '0x25f32ec89ce7732a4e9f8f3340a09259f823b7d3': { + chainId: 8453, + tokenAddress: '0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf' + }, + '0x989381f7efb45f97e46be9f390a69c5d94bf9e17': { + chainId: 8453, + tokenAddress: '0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22' + }, + '0x252b965400862d94bda35fecf7ee0f204a53cc36': { + chainId: 42161, + tokenAddress: '0x4ecf61a6c2fab8a047ceb3b3b263b401763e9d49' + }, + '0x9fa306b1f4a6a83fec98d8ebbabedff78c407f6b': { + chainId: 42161, + tokenAddress: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8' + }, + '0x6faf8b7ffee3306efcfc2ba9fec912b4d49834c1': { + chainId: 42161, + tokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831' + }, + '0xc0ba9bfed28ab46da48d2b69316a3838698ef3f5': { + chainId: 42161, + tokenAddress: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9' + }, + '0x7deb119b92b76f78c212bc54fbbb34cea75f4d4a': { + chainId: 42161, + tokenAddress: '0x912ce59144191c1204e64559fe8253a0e49e6548' + }, + '0x34b9421fe3d52191b64bc32ec1ab764dcbcdbf5e': { + chainId: 137, + tokenAddress: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359' + }, + '0xa013fbd4b711f9ded6fb09c1c0d358e2fbc2eaa0': { + chainId: 137, + tokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174' + }, + '0xbb287e6017d3deb0e2e65061e8684eab21060123': { + chainId: 137, + tokenAddress: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f' + }, + '0x90b2f54c6addad41b8f6c4fccd555197bc0f773b': { + chainId: 137, + tokenAddress: '0x8f3cf7ad23cd3cadbd9735aff958023239c6a063' + }, + '0x305f25377d0a39091e99b975558b1bdfc3975654': { + chainId: 137, + tokenAddress: '0x7ceb23fd6bc0add59e62ac25578270cff1b9f619' + }, + '0x28f53ba70e5c8ce8d03b1fad41e9df11bb646c36': { + chainId: 137, + tokenAddress: '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270' + } +} + +function _inferVaultTokenSymbol(vaultLabel: string | null): string | null { + if (!vaultLabel) { + return null + } + + const firstToken = vaultLabel.trim().split(/\s+/)[0] + if (!firstToken) { + return null + } + + const symbol = firstToken.replace(/-\d+$/, '') + return symbol || null +} + +export function getVaultAssetToken(vaultAddress: string): VaultAssetToken | null { + return VAULT_ASSET_TOKENS[vaultAddress.toLowerCase()] ?? null +} + +export function getChainLogoUrl(chainId: number): string { + return `https://token-assets-one.vercel.app/api/chains/${chainId}/logo-32.png?fallback=true` +} + +export function getTokenLogoUrl(chainId: number, tokenAddress: string): string { + return `https://token-assets-one.vercel.app/api/tokens/${chainId}/${tokenAddress.toLowerCase()}/logo-32.png?fallback=true` +} + +const VAULT_DECIMALS: Record = { + '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204': 6, + '0xae7d8db82480e6d8e3873ecbf22cf17b3d8a7308': 6, + '0xb13cf163d916917d9cd6e836905ca5f12a1def4b': 6, + '0xc3bd0a2193c8f027b82dde3611d18589ef3f62a9': 6, + '0x9fa306b1f4a6a83fec98d8ebbabedff78c407f6b': 6, + '0x6faf8b7ffee3306efcfc2ba9fec912b4d49834c1': 6, + '0x34b9421fe3d52191b64bc32ec1ab764dcbcdbf5e': 6, + '0xa013fbd4b711f9ded6fb09c1c0d358e2fbc2eaa0': 6, + '0x7b5a0182e400b241b317e781a4e9dedfc1429822': 6, + '0xf470eb50b4a60c9b069f7fd6032532b8f5cc014d': 6, + '0x074134a2784f4f66b6ced6f68849382990ff3215': 6, + '0x0e297de4005883c757c9f09fdf7cf1363c20e626': 6, + '0x694e47afd14a64661a04eee674fb331bcdef3737': 6, + '0x00c8a649c9837523ebb406ceb17a6378ab5c74cf': 6, + '0xb739ae19620f7ecb4fb84727f205453aa5bc1ad2': 6, + '0xd811a47cfd17355f47ac49be02c4744a926dd16b': 6, + '0x5bfd56f9bcbdb2be985c64c620eca7f02fa7b439': 6, + '0x80c34bd3a3569e126e7055831036aa7b212cb159': 6, + '0x310b7ea7475a0b449cfd73be81522f1b88efafaa': 6, + '0xc0ba9bfed28ab46da48d2b69316a3838698ef3f5': 6, + '0xbb287e6017d3deb0e2e65061e8684eab21060123': 6, + '0x48c03b6ffd0008460f8657db1037c7e09deedfcb': 6, + '0xa5dab32dbe68e6fa784e1e50e4f620a0477d3896': 6, + '0x4bd05e6ff75b633f504f0fc501c1e257578c8a72': 6, + '0xe30461f1270ba52d45df1e773aefe594c7e430dc': 6, + '0x9a6bd7b6fd5c4f87eb66356441502fc7dcdd185b': 6, + '0x751f0cc6115410a3ee9ec92d08f46ff6da98b708': 8, + '0x25f32ec89ce7732a4e9f8f3340a09259f823b7d3': 8, + '0x92c82f5f771f6a44cfa09357dd0575b81bf5f728': 8, + '0xe1ac97e2616ad80f69f705ff007a4bbb3655544a': 8, + '0xaa0362ecc584b985056e47812931270b99c91f9d': 8 +} + +export function getVaultDecimals(vaultAddress: string): number { + return VAULT_DECIMALS[vaultAddress.toLowerCase()] ?? 18 +} + +const SYMBOL_TOKEN_BY_CHAIN: Record> = { + 1: { + usdc: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + dai: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + usdt: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + usds: '0xdC035D45d973E3EC169d2276DDab16f1e407384F', + crvusd: '0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E', + weth: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + wbtc: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + sky: '0x56072c95faa701256059aa122697b133aded9279', + ybold: '0x6440f144b7e50d6a8439336510312d2f54beb01d', + susdaf: '0x9cf12ccd6020b6888e4d4c4e4c7aca33c1eb91f8', + yyb: '0x22222222aea0076fca927a3f44dc0b4fdf9479d6' + }, + 8453: { + usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54BDA02913', + weth: '0x4200000000000000000000000000000000000006', + cbbtc: '0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf', + cbeth: '0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22' + }, + 42161: { + usnd: '0x4ecf61a6c2fab8a047ceb3b3b263b401763e9d49', + usdc: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + 'usdc.e': '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + usdt: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', + arb: '0x912ce59144191c1204e64559fe8253a0e49e6548' + }, + 137: { + usdc: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + 'usdc.e': '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + usdt: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', + dai: '0x8f3cf7ad23cd3cadbd9735aff958023239c6a063', + weth: '0x7ceb23fd6bc0add59e62ac25578270cff1b9f619', + wmatic: '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270', + matic: '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270' + } +} + +function normalizeSymbol(symbol: string): string { + return symbol.trim().toLowerCase() +} + +export function getTokenAddressForSymbol(chainId: number, tokenSymbol: string): string | null { + return SYMBOL_TOKEN_BY_CHAIN[chainId]?.[normalizeSymbol(tokenSymbol)] ?? null +} diff --git a/api/optimization/_lib/colors.ts b/api/optimization/_lib/colors.ts new file mode 100644 index 000000000..195472ff5 --- /dev/null +++ b/api/optimization/_lib/colors.ts @@ -0,0 +1,50 @@ +const PALETTE = [ + '#0074D9', + '#40D3A4', + '#FFB800', + '#F05138', + '#0657F9', + '#FFBF00', + '#00A650', + '#00796D', + '#3b82f6', + '#10b981', + '#f59e0b', + '#ef4444', + '#8b5cf6', + '#06b6d4', + '#f97316', + '#ec4899', + '#84cc16', + '#6366f1', + '#14b8a6', + '#d946ef' +] + +function hashAddress(address: string): number { + let hash = 5381 + for (let i = 0; i < address.length; i++) { + hash = (hash * 33) ^ address.charCodeAt(i) + } + return Math.abs(hash) +} + +export function assignStrategyColors(addresses: string[]): Map { + const result = new Map() + const usedIndices = new Set() + + for (const address of addresses) { + let index = hashAddress(address) % PALETTE.length + + let attempts = 0 + while (usedIndices.has(index) && attempts < PALETTE.length) { + index = (index + 1) % PALETTE.length + attempts++ + } + + usedIndices.add(index) + result.set(address, PALETTE[index]) + } + + return result +} diff --git a/api/optimization/_lib/envio.ts b/api/optimization/_lib/envio.ts new file mode 100644 index 000000000..80b1cb655 --- /dev/null +++ b/api/optimization/_lib/envio.ts @@ -0,0 +1,219 @@ +const KEEPER_ADDRESS = '0x283132390eA87D6ecc20255B59Ba94329eE17961' + +const DEBT_UPDATES_QUERY = ` +query DebtUpdatesForVault( + $vaultAddress: String!, + $chainId: Int!, + $fromTs: Int!, + $toTs: Int!, + $senderAddress: String!, + $limit: Int!, + $offset: Int! +) { + debtUpdates: DebtUpdated( + where: { + vaultAddress: { _ilike: $vaultAddress }, + chainId: { _eq: $chainId }, + blockTimestamp: { _gte: $fromTs, _lte: $toTs }, + transactionFrom: { _ilike: $senderAddress } + } + order_by: { blockTimestamp: asc, blockNumber: asc, logIndex: asc } + limit: $limit + offset: $offset + ) { + strategy + current_debt + new_debt + blockNumber + blockTimestamp + transactionHash + logIndex + } +} +` + +export interface DebtUpdatedEvent { + transactionHash: string + strategy: string + blockTimestamp: number + blockTimestampUtc: string + blockNumber: number | null + deltaToken: number +} + +interface RawDebtEvent { + transactionHash?: string | null + strategy?: string | null + current_debt?: string | number | null + new_debt?: string | number | null + blockTimestamp?: string | number | null + blockNumber?: string | number | null + logIndex?: string | number | null +} + +interface StrategyDebtRatio { + strategy: string + currentRatio: number + targetRatio: number +} + +const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/i + +function isAddress(value: string): boolean { + return ADDRESS_PATTERN.test(value) +} + +function signOfNumber(value: number): number { + if (value > 0) return 1 + if (value < 0) return -1 + return 0 +} + +function compareIntStrings(a: string, b: string): number { + const cleanA = a.replace(/^0+/, '') || '0' + const cleanB = b.replace(/^0+/, '') || '0' + if (cleanA.length !== cleanB.length) return cleanA.length - cleanB.length + return cleanA.localeCompare(cleanB) +} + +function debtDeltaSign(currentDebt: string, newDebt: string): number { + const cmp = compareIntStrings(currentDebt, newDebt) + if (cmp < 0) return 1 + if (cmp > 0) return -1 + return 0 +} + +function deltaToFloat(currentDebt: string, newDebt: string, decimals: number): number { + const divisor = 10 ** decimals + return Number(newDebt) / divisor - Number(currentDebt) / divisor +} + +async function graphqlPost( + url: string, + query: string, + variables: Record +): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json' + } + if (process.env.ENVIO_PASSWORD) { + headers.Authorization = `Bearer ${process.env.ENVIO_PASSWORD}` + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }) + }) + + if (!response.ok) { + throw new Error(`Envio GraphQL request failed: HTTP ${response.status}`) + } + + const data = await response.json() + + if ((data as { errors?: Array<{ message?: string }> }).errors) { + const messages = ((data as { errors?: Array<{ message?: string }> }).errors ?? []) + .map((e) => e.message ?? String(e)) + .join('; ') + throw new Error(`GraphQL errors: ${messages}`) + } + + return (data as { data: Record }).data +} + +async function fetchDebtUpdates( + envioUrl: string, + vault: string, + chainId: number, + fromTs: number, + toTs: number, + pageSize = 400, + maxEvents = 3000 +): Promise { + const events: RawDebtEvent[] = [] + let offset = 0 + + while (events.length < maxEvents) { + const data = await graphqlPost(envioUrl, DEBT_UPDATES_QUERY, { + vaultAddress: vault, + chainId, + fromTs, + toTs, + senderAddress: KEEPER_ADDRESS, + limit: pageSize, + offset + }) + + const page = (data as { debtUpdates?: RawDebtEvent[] }).debtUpdates + if (!Array.isArray(page) || page.length === 0) break + + events.push(...page.filter((item) => item && typeof item === 'object')) + if (page.length < pageSize) break + + offset += pageSize + } + + return events.slice(0, maxEvents) +} + +export async function fetchAlignedEvents( + envioUrl: string, + vault: string, + chainId: number, + strategyDebtRatios: StrategyDebtRatio[], + fromTs: number, + toTs: number, + decimals = 18, + minExpectedBps = 25 +): Promise { + const expectedBps = new Map() + for (const ratio of strategyDebtRatios) { + if (!ratio.strategy || !isAddress(ratio.strategy)) continue + const deltaBps = ratio.targetRatio - ratio.currentRatio + if (Math.abs(deltaBps) < minExpectedBps) continue + const addr = ratio.strategy.toLowerCase() + expectedBps.set(addr, (expectedBps.get(addr) ?? 0) + deltaBps) + } + + const rawEvents = await fetchDebtUpdates(envioUrl, vault, chainId, fromTs, toTs) + + const matched: DebtUpdatedEvent[] = [] + + for (const event of rawEvents) { + const strategyRaw = event.strategy + if (!strategyRaw || typeof strategyRaw !== 'string' || !isAddress(strategyRaw)) continue + + const txHash = event.transactionHash + if (!txHash || typeof txHash !== 'string') continue + + const currentDebtStr = String(event.current_debt ?? '0') + const newDebtStr = String(event.new_debt ?? '0') + const blockTs = Number(event.blockTimestamp) + + if (!currentDebtStr || !newDebtStr || !Number.isFinite(blockTs)) continue + + const deltaSign = debtDeltaSign(currentDebtStr, newDebtStr) + if (deltaSign === 0) continue + + const expected = expectedBps.get(strategyRaw.toLowerCase()) + if (expected === undefined) continue + + if (signOfNumber(expected) !== deltaSign) continue + + const blockTs_s = Math.floor(blockTs) + const utc = new Date(blockTs_s * 1000).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' + + matched.push({ + transactionHash: txHash, + strategy: strategyRaw, + blockTimestamp: blockTs_s, + blockTimestampUtc: utc, + blockNumber: event.blockNumber != null ? Number(event.blockNumber) : null, + deltaToken: deltaToFloat(currentDebtStr, newDebtStr, decimals) + }) + } + + return matched +} diff --git a/api/optimization/_lib/explain-parse.ts b/api/optimization/_lib/explain-parse.ts new file mode 100644 index 000000000..461d7e8ad --- /dev/null +++ b/api/optimization/_lib/explain-parse.ts @@ -0,0 +1,155 @@ +export interface ExplainMetadata { + vaultLabel: string | null + chainId: number | null + chainName: string | null + tvl: number | null + tvlUnit: string | null + optimizationMethod: string | null + changesFiltered: number | null +} + +export interface ExplainNoChangeStrategy { + name: string + currentRatio: number + targetRatio: number + currentApr: number | null + targetApr: number | null +} + +const EXPLAIN_VAULT_LINE_PATTERN = /^(.+)\s+\((\d+):\s*(0x[a-fA-F0-9]{40})\)/ +const EXPLAIN_TVL_LINE_PATTERN = /^TVL:\s*(.+)$/im +const EXPLAIN_TVL_VALUE_PATTERN = /^\s*(\$)?\s*([\d,]+(?:\.\d+)?)\s*([A-Za-z][A-Za-z0-9._-]*)?\s*$/ +const EXPLAIN_OPTIMIZATION_PATTERN = /Optimization:\s*(.+)/ +const EXPLAIN_CHANGES_FILTERED_PATTERN = /Changes filtered:\s*(\d+)/ +const FILTERED_NO_CHANGE_LINE_PATTERN = /^\s{2}(.+?):\s*(-?\d+(?:\.\d+)?)%\s*=>\s*no change \(filtered\)\s*$/i +const STRATEGY_APR_LINE_PATTERN = /^\s*\((-?\d+(?:\.\d+)?)%\)\s*\((-?\d+(?:\.\d+)?)%\s*=>\s*(-?\d+(?:\.\d+)?)%\)\s*$/ + +function getChainName(chainId: number | null): string | null { + if (chainId === null) return null + const chains: Record = { + 1: 'Mainnet', + 10: 'Optimism', + 137: 'Polygon', + 42161: 'Arbitrum', + 8453: 'Base', + 250: 'Fantom' + } + return chains[chainId] ?? `Chain ${chainId}` +} + +function inferVaultTokenSymbol(vaultLabel: string | null): string | null { + if (!vaultLabel) { + return null + } + + const firstToken = vaultLabel.trim().split(/\s+/)[0] + if (!firstToken) { + return null + } + + const symbol = firstToken.replace(/-\d+$/, '') + return symbol || null +} + +function parseTvl(explain: string, vaultLabel: string | null): { tvl: number | null; tvlUnit: string | null } { + const tvlLineMatch = explain.match(EXPLAIN_TVL_LINE_PATTERN) + if (!tvlLineMatch?.[1]) { + return { tvl: null, tvlUnit: null } + } + + const tvlValueMatch = tvlLineMatch[1].trim().match(EXPLAIN_TVL_VALUE_PATTERN) + if (!tvlValueMatch?.[2]) { + return { tvl: null, tvlUnit: null } + } + + const tvl = Number.parseFloat(tvlValueMatch[2].replace(/,/g, '')) + if (!Number.isFinite(tvl)) { + return { tvl: null, tvlUnit: null } + } + + const hasDollarPrefix = Boolean(tvlValueMatch[1]) + const rawUnit = tvlValueMatch[3]?.trim() ?? null + let tvlUnit: string | null + + if (hasDollarPrefix || rawUnit?.toLowerCase() === 'usd') { + tvlUnit = 'USD' + } else if (rawUnit) { + tvlUnit = rawUnit + } else { + tvlUnit = inferVaultTokenSymbol(vaultLabel) + } + + return { tvl, tvlUnit } +} + +export function parseExplainMetadata(explain: string): ExplainMetadata { + const lines = explain.split('\n') + const firstLine = lines.find((line) => EXPLAIN_VAULT_LINE_PATTERN.test(line)) ?? '' + + const vaultMatch = firstLine.match(EXPLAIN_VAULT_LINE_PATTERN) + const optimizationMatch = explain.match(EXPLAIN_OPTIMIZATION_PATTERN) + const changesFilteredMatch = explain.match(EXPLAIN_CHANGES_FILTERED_PATTERN) + + const chainId = vaultMatch?.[2] ? parseInt(vaultMatch[2], 10) : null + const vaultLabel = vaultMatch?.[1]?.trim() ?? null + const { tvl, tvlUnit } = parseTvl(explain, vaultLabel) + + return { + vaultLabel, + chainId, + chainName: getChainName(chainId), + tvl, + tvlUnit, + optimizationMethod: optimizationMatch?.[1]?.trim() ?? null, + changesFiltered: changesFilteredMatch?.[1] ? parseInt(changesFilteredMatch[1], 10) : null + } +} + +function percentToBps(percent: number): number { + return Math.round(percent * 100) +} + +export function parseFilteredNoChangeStrategies(explain: string): ExplainNoChangeStrategy[] { + const lines = explain.split('\n') + const strategies: ExplainNoChangeStrategy[] = [] + + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(FILTERED_NO_CHANGE_LINE_PATTERN) + if (!match) { + continue + } + + const name = match[1]?.trim() + const currentRatioPct = Number.parseFloat(match[2]) + if (!name || !Number.isFinite(currentRatioPct) || currentRatioPct <= 0) { + continue + } + + let currentApr: number | null = null + let targetApr: number | null = null + const aprLine = lines[index + 1] + if (aprLine) { + const aprMatch = aprLine.match(STRATEGY_APR_LINE_PATTERN) + if (aprMatch) { + const currentAprPct = Number.parseFloat(aprMatch[2]) + const targetAprPct = Number.parseFloat(aprMatch[3]) + if (Number.isFinite(currentAprPct) && currentAprPct >= 0) { + currentApr = percentToBps(currentAprPct) + } + if (Number.isFinite(targetAprPct) && targetAprPct >= 0) { + targetApr = percentToBps(targetAprPct) + } + } + } + + strategies.push({ + name, + currentRatio: percentToBps(currentRatioPct), + targetRatio: percentToBps(currentRatioPct), + currentApr, + targetApr + }) + } + + return strategies +} diff --git a/api/optimization/_lib/formatters.ts b/api/optimization/_lib/formatters.ts new file mode 100644 index 000000000..5ab914bba --- /dev/null +++ b/api/optimization/_lib/formatters.ts @@ -0,0 +1,4 @@ +export function formatAddress(address: string): string { + if (address.length < 10) return address + return `${address.slice(0, 6)}...${address.slice(-4)}` +} diff --git a/api/optimization/_lib/normalize.ts b/api/optimization/_lib/normalize.ts new file mode 100644 index 000000000..b6d0e1c2c --- /dev/null +++ b/api/optimization/_lib/normalize.ts @@ -0,0 +1,306 @@ +import { getChainLogoUrl, getTokenAddressForSymbol, getTokenLogoUrl, getVaultAssetToken } from './assetLogos' +import { assignStrategyColors } from './colors' +import { parseExplainMetadata, parseFilteredNoChangeStrategies } from './explain-parse' +import { formatAddress } from './formatters' +import type { OptimizationSourceMeta } from './redis' +import type { StrategyDebtRatio, VaultOptimization } from './schema' + +const TOTAL_BPS = 10000 +export const NORMALIZATION_TOLERANCE_BPS = 5 + +function buildSyntheticStrategyAddress(vaultAddress: string, strategyName: string, index: number, salt = 0): string { + const seed = `${vaultAddress.toLowerCase()}|${strategyName.toLowerCase()}|${index}|${salt}` + const bytes = new Uint8Array(20) + + for (let byteIndex = 0; byteIndex < bytes.length; byteIndex += 1) { + bytes[byteIndex] = (byteIndex * 29 + 17) & 0xff + } + + for (let charIndex = 0; charIndex < seed.length; charIndex += 1) { + const code = seed.charCodeAt(charIndex) + const slot = charIndex % bytes.length + const mixed = (bytes[slot] * 33 + code + ((charIndex * 13) & 0xff)) & 0xff + bytes[slot] = mixed + } + + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + return `0x${hex}` +} + +export interface NormalizedStrategy { + strategy: string + strategyAddress: string | null + name: string + isVaultName: boolean + isUnallocated: boolean + currentRatio: number + targetRatio: number + allocationDeltaBps: number + currentRatioPct: number + targetRatioPct: number + allocationDeltaPct: number + currentApr: number | null + targetApr: number | null + aprDeltaBps: number | null + currentAprPct: number | null + targetAprPct: number | null + aprDeltaPct: number | null + color: string +} + +export interface NormalizedChange { + vault: string + vaultLabel: string + chainId: number | null + chainName: string | null + tvl: number | null + tvlUnit: string | null + explain: string + optimizationMethod: string | null + changesFiltered: number | null + strategies: NormalizedStrategy[] + vaultAprCurrentPct: number + vaultAprProposedPct: number + vaultAprDeltaPct: number + vaultAprDeltaRelativePct: number | null + hasUnallocated: boolean + unallocatedBps: number + tokenLogoUrl: string | null + chainLogoUrl: string | null + timestampUtc: string | null + isLatestAlias: boolean + sourceKey: string +} + +function augmentStrategiesFromExplain( + raw: VaultOptimization, + strategies: StrategyDebtRatio[] +): { strategies: StrategyDebtRatio[]; syntheticStrategyKeysByAddress: Map } { + const dedupedInputStrategies: StrategyDebtRatio[] = [] + const seenInputAddresses = new Set() + for (const strategy of strategies) { + const normalizedAddress = strategy.strategy.toLowerCase() + if (seenInputAddresses.has(normalizedAddress)) { + continue + } + seenInputAddresses.add(normalizedAddress) + dedupedInputStrategies.push(strategy) + } + + const currentSum = dedupedInputStrategies.reduce((sum, strategy) => sum + strategy.currentRatio, 0) + const targetSum = dedupedInputStrategies.reduce((sum, strategy) => sum + strategy.targetRatio, 0) + const hasAllocationGap = + currentSum < TOTAL_BPS - NORMALIZATION_TOLERANCE_BPS || targetSum < TOTAL_BPS - NORMALIZATION_TOLERANCE_BPS + + if (!hasAllocationGap) { + return { strategies: dedupedInputStrategies, syntheticStrategyKeysByAddress: new Map() } + } + + const filteredNoChangeStrategies = parseFilteredNoChangeStrategies(raw.explain) + if (filteredNoChangeStrategies.length === 0) { + return { strategies: dedupedInputStrategies, syntheticStrategyKeysByAddress: new Map() } + } + + const existingNames = new Set( + dedupedInputStrategies + .map((strategy) => strategy.name?.trim().toLowerCase()) + .filter((name): name is string => Boolean(name)) + ) + + const augmentedStrategies = [...dedupedInputStrategies] + const syntheticStrategyKeysByAddress = new Map() + const usedStrategyAddresses = new Set(dedupedInputStrategies.map((strategy) => strategy.strategy.toLowerCase())) + let syntheticIndex = 0 + + for (const filteredStrategy of filteredNoChangeStrategies) { + const normalizedName = filteredStrategy.name.trim().toLowerCase() + if (normalizedName && existingNames.has(normalizedName)) { + continue + } + + if (normalizedName) { + existingNames.add(normalizedName) + } + + syntheticIndex += 1 + let salt = 0 + let syntheticAddress = buildSyntheticStrategyAddress(raw.vault, filteredStrategy.name, syntheticIndex, salt) + while (usedStrategyAddresses.has(syntheticAddress.toLowerCase())) { + salt += 1 + syntheticAddress = buildSyntheticStrategyAddress(raw.vault, filteredStrategy.name, syntheticIndex, salt) + } + + const normalizedSyntheticAddress = syntheticAddress.toLowerCase() + usedStrategyAddresses.add(normalizedSyntheticAddress) + syntheticStrategyKeysByAddress.set( + normalizedSyntheticAddress, + `synthetic:${raw.vault.toLowerCase()}:${normalizedName}:${syntheticIndex}` + ) + augmentedStrategies.push({ + strategy: syntheticAddress, + name: filteredStrategy.name, + currentRatio: filteredStrategy.currentRatio, + targetRatio: filteredStrategy.targetRatio, + currentApr: filteredStrategy.currentApr ?? undefined, + targetApr: filteredStrategy.targetApr ?? filteredStrategy.currentApr ?? undefined + }) + } + + return { strategies: augmentedStrategies, syntheticStrategyKeysByAddress } +} + +function normalizeStrategyNames(strategies: StrategyDebtRatio[]): StrategyDebtRatio[] { + return strategies.map((strategy, index) => ({ + ...strategy, + name: strategy.name?.trim() || `Strategy ${index + 1}` + })) +} + +export function normalizeChange(data: VaultOptimization, source?: OptimizationSourceMeta): NormalizedChange { + const metadata = parseExplainMetadata(data.explain) + + const { strategies: augmentedStrategies, syntheticStrategyKeysByAddress } = augmentStrategiesFromExplain( + data, + data.strategyDebtRatios + ) + const inputStrategies = normalizeStrategyNames(augmentedStrategies) + + let strategies = inputStrategies + let hasUnallocated = false + let unallocatedBps = 0 + + const totalCurrentBps = strategies.reduce((sum, s) => sum + s.currentRatio, 0) + const totalTargetBps = strategies.reduce((sum, s) => sum + s.targetRatio, 0) + + const unallocatedCurrentBps = Math.max(0, TOTAL_BPS - totalCurrentBps) + const unallocatedTargetBps = Math.max(0, TOTAL_BPS - totalTargetBps) + + if (unallocatedCurrentBps > NORMALIZATION_TOLERANCE_BPS || unallocatedTargetBps > NORMALIZATION_TOLERANCE_BPS) { + hasUnallocated = unallocatedCurrentBps > NORMALIZATION_TOLERANCE_BPS + unallocatedBps = unallocatedCurrentBps + + strategies = [ + ...strategies, + { + strategy: 'unallocated', + name: 'Unallocated', + currentRatio: unallocatedCurrentBps, + targetRatio: unallocatedTargetBps, + currentApr: undefined, + targetApr: undefined + } as StrategyDebtRatio + ] + } + + const strategyKeys: string[] = [] + const usedKeys = new Set() + for (const strategy of strategies) { + const normalizedAddress = strategy.strategy.toLowerCase() + const baseKey = syntheticStrategyKeysByAddress.get(normalizedAddress) ?? normalizedAddress + let resolvedKey = baseKey + let duplicateCounter = 1 + while (usedKeys.has(resolvedKey)) { + duplicateCounter += 1 + resolvedKey = `${baseKey}#${duplicateCounter}` + } + usedKeys.add(resolvedKey) + strategyKeys.push(resolvedKey) + } + + const colorMap = assignStrategyColors(strategyKeys) + + const normalizedStrategies: NormalizedStrategy[] = strategies.map((strategy, index) => { + const currentApr = strategy.currentApr ?? null + const targetApr = strategy.targetApr ?? null + const allocationDeltaBps = strategy.targetRatio - strategy.currentRatio + const aprDeltaBps = currentApr !== null && targetApr !== null ? targetApr - currentApr : null + + const normalizedAddress = strategy.strategy.toLowerCase() + const baseSyntheticKey = syntheticStrategyKeysByAddress.get(normalizedAddress) + const strategyKey = strategyKeys[index] + + const isUnallocated = strategy.strategy === 'unallocated' + + return { + strategy: strategyKey, + strategyAddress: baseSyntheticKey || isUnallocated ? null : strategy.strategy, + name: strategy.name!, + isVaultName: false, + isUnallocated, + currentRatio: strategy.currentRatio, + targetRatio: strategy.targetRatio, + allocationDeltaBps, + currentRatioPct: strategy.currentRatio / 100, + targetRatioPct: strategy.targetRatio / 100, + allocationDeltaPct: allocationDeltaBps / 100, + currentApr, + targetApr, + aprDeltaBps, + currentAprPct: currentApr !== null ? currentApr / 100 : null, + targetAprPct: targetApr !== null ? targetApr / 100 : null, + aprDeltaPct: aprDeltaBps !== null ? aprDeltaBps / 100 : null, + color: isUnallocated ? '#9ca3af' : (colorMap.get(strategyKey) ?? '#9ca3af') + } + }) + + const vaultAprCurrentPct = data.currentApr / 100 + const vaultAprProposedPct = data.proposedApr / 100 + const vaultAprDeltaPct = vaultAprProposedPct - vaultAprCurrentPct + const vaultAprDeltaRelativePct = vaultAprCurrentPct > 0 ? (vaultAprDeltaPct / vaultAprCurrentPct) * 100 : null + + const chainLogoUrl = metadata.chainId !== null ? getChainLogoUrl(metadata.chainId) : null + + let tokenLogoUrl: string | null = null + const knownAsset = getVaultAssetToken(data.vault) + if (knownAsset) { + tokenLogoUrl = getTokenLogoUrl(knownAsset.chainId, knownAsset.tokenAddress) + } else if (metadata.chainId !== null) { + const tokenSymbol = inferVaultTokenSymbol(metadata.vaultLabel) + if (tokenSymbol) { + const tokenAddress = getTokenAddressForSymbol(metadata.chainId, tokenSymbol) + if (tokenAddress) { + tokenLogoUrl = getTokenLogoUrl(metadata.chainId, tokenAddress) + } + } + } + + const timestampUtc = source?.isLatestAlias ? source.latestMatchedTimestampUtc : (source?.timestampUtc ?? null) + + return { + vault: data.vault, + vaultLabel: metadata.vaultLabel ?? formatAddress(data.vault), + chainId: metadata.chainId, + chainName: metadata.chainName, + tvl: metadata.tvl, + tvlUnit: metadata.tvlUnit, + explain: data.explain, + optimizationMethod: metadata.optimizationMethod, + changesFiltered: metadata.changesFiltered, + strategies: normalizedStrategies, + vaultAprCurrentPct, + vaultAprProposedPct, + vaultAprDeltaPct, + vaultAprDeltaRelativePct, + hasUnallocated, + unallocatedBps, + tokenLogoUrl, + chainLogoUrl, + timestampUtc, + isLatestAlias: source?.isLatestAlias ?? false, + sourceKey: source?.key ?? 'unknown' + } +} + +function inferVaultTokenSymbol(vaultLabel: string | null): string | null { + if (!vaultLabel) { + return null + } + + const firstToken = vaultLabel.trim().split(/\s+/)[0] + if (!firstToken) { + return null + } + + const symbol = firstToken.replace(/-\d+$/, '') + return symbol || null +} diff --git a/api/optimization/_lib/redis.ts b/api/optimization/_lib/redis.ts new file mode 100644 index 000000000..d5cbdd072 --- /dev/null +++ b/api/optimization/_lib/redis.ts @@ -0,0 +1,326 @@ +import { Redis } from '@upstash/redis' +import type { VaultOptimization } from './schema' +import { parseVaultOptimizations } from './schema' + +const OPTIMIZATIONS_KEY_PATTERN = 'doa:optimizations:*' +const OPTIMIZATIONS_KEY_REGEX = /^doa:optimizations:(\d+):(.+)$/ +const MIN_UNIX_SECONDS = 946684800 +const MAX_UNIX_SECONDS = 4102444800 +const LOCAL_CACHE_TTL_MS = 60 * 1000 + +interface OptimizationKeyInfo { + key: string + chainId: number | null + revision: string + revisionUnixSeconds: number | null + revisionTimestampUtc: string | null + isLatestAlias: boolean +} + +interface OptimizationPayloadRecord { + keyInfo: OptimizationKeyInfo + rawPayload: string +} + +export interface OptimizationSourceMeta { + key: string + chainId: number | null + revision: string + isLatestAlias: boolean + timestampUtc: string | null + latestMatchedTimestampUtc: string | null +} + +export type VaultOptimizationRecord = VaultOptimization & { + source: OptimizationSourceMeta +} + +interface ReadOptimizationsCache { + value: VaultOptimizationRecord[] | null + expiresAtMs: number +} + +let client: Redis | null = null +let optimizationsCache: ReadOptimizationsCache | null = null +let optimizationsInFlight: Promise | null = null + +function getClient(): Redis { + if (!client) { + const url = process.env.UPSTASH_REDIS_REST_URL + const username = process.env.UPSTASH_REDIS_REST_USERNAME + const token = process.env.UPSTASH_REDIS_REST_TOKEN + if (!url || (username && !token) || (!username && token)) { + throw new RedisConnectivityError('UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must both be set') + } + + client = new Redis({ url, token }) + } + + return client +} + +export class RedisConnectivityError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message) + this.name = 'RedisConnectivityError' + if (options?.cause !== undefined) { + ;(this as Error & { cause?: unknown }).cause = options.cause + } + } +} + +export function isRedisConnectivityError(error: unknown): error is RedisConnectivityError { + return error instanceof RedisConnectivityError +} + +export class RedisAuthenticationError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message) + this.name = 'RedisAuthenticationError' + if (options?.cause !== undefined) { + ;(this as Error & { cause?: unknown }).cause = options.cause + } + } +} + +export function isRedisAuthenticationError(error: unknown): error is RedisAuthenticationError { + return error instanceof RedisAuthenticationError +} + +function classifyError(error: unknown): Error { + if (!(error instanceof Error)) { + return new Error(String(error), { cause: error }) + } + + const message = error.message.toLowerCase() + + if (message.includes('unauthorized') || message.includes('invalid token')) { + return new RedisAuthenticationError('Redis authentication failed', { cause: error }) + } + + if ( + message.includes('fetch failed') || + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('timeout') || + message.includes('network') + ) { + return new RedisConnectivityError('Unable to reach Redis backend', { cause: error }) + } + + return error +} + +function formatUtcTimestampFromUnixSeconds(unixSeconds: number): string { + return `${new Date(unixSeconds * 1000).toISOString().slice(0, 19).replace('T', ' ')} UTC` +} + +function parseOptimizationKey(key: string): OptimizationKeyInfo { + const match = key.match(OPTIMIZATIONS_KEY_REGEX) + if (!match) { + return { + key, + chainId: null, + revision: '', + revisionUnixSeconds: null, + revisionTimestampUtc: null, + isLatestAlias: false + } + } + + const chainId = Number.parseInt(match[1], 10) + const revision = match[2] + const isLatestAlias = revision === 'latest' + const parsedRevision = Number.parseInt(revision, 10) + const isUnixSeconds = + !isLatestAlias && + /^\d+$/.test(revision) && + Number.isFinite(parsedRevision) && + parsedRevision >= MIN_UNIX_SECONDS && + parsedRevision <= MAX_UNIX_SECONDS + const revisionUnixSeconds = isUnixSeconds ? parsedRevision : null + + return { + key, + chainId, + revision, + revisionUnixSeconds, + revisionTimestampUtc: revisionUnixSeconds !== null ? formatUtcTimestampFromUnixSeconds(revisionUnixSeconds) : null, + isLatestAlias + } +} + +function keySortRank(key: string): { chainId: number | null; revisionRank: number } { + const keyInfo = parseOptimizationKey(key) + if (keyInfo.chainId === null) { + return { chainId: null, revisionRank: Number.MIN_SAFE_INTEGER } + } + + if (keyInfo.isLatestAlias) { + return { chainId: keyInfo.chainId, revisionRank: Number.MAX_SAFE_INTEGER } + } + + return { + chainId: keyInfo.chainId, + revisionRank: keyInfo.revisionUnixSeconds ?? Number.MIN_SAFE_INTEGER + } +} + +function sortOptimizationKeys(keys: string[]): string[] { + return [...new Set(keys)].sort((left, right) => { + const leftRank = keySortRank(left) + const rightRank = keySortRank(right) + + if (leftRank.chainId === null && rightRank.chainId !== null) { + return 1 + } + if (leftRank.chainId !== null && rightRank.chainId === null) { + return -1 + } + if (leftRank.chainId !== null && rightRank.chainId !== null && leftRank.chainId !== rightRank.chainId) { + return leftRank.chainId - rightRank.chainId + } + if (leftRank.revisionRank !== rightRank.revisionRank) { + return rightRank.revisionRank - leftRank.revisionRank + } + + return left.localeCompare(right) + }) +} + +async function readOptimizationKeys(): Promise { + const keys: string[] = [] + let cursor = '0' + + do { + const [nextCursor, foundKeys] = await getClient().scan(cursor, { + match: OPTIMIZATIONS_KEY_PATTERN, + count: 500 + }) + cursor = nextCursor + keys.push(...foundKeys) + } while (cursor !== '0') + + return sortOptimizationKeys(keys) +} + +function findLatestAliasTimestamp( + keyInfo: OptimizationKeyInfo, + rawPayload: string, + records: OptimizationPayloadRecord[] +): string | null { + if (!keyInfo.isLatestAlias || keyInfo.chainId === null) { + return null + } + + let newestMatchingTimestamp: number | null = null + for (const record of records) { + if (record.keyInfo.chainId !== keyInfo.chainId) { + continue + } + if (record.keyInfo.revisionUnixSeconds === null) { + continue + } + if (record.rawPayload !== rawPayload) { + continue + } + if (newestMatchingTimestamp === null || record.keyInfo.revisionUnixSeconds > newestMatchingTimestamp) { + newestMatchingTimestamp = record.keyInfo.revisionUnixSeconds + } + } + + return newestMatchingTimestamp === null ? null : formatUtcTimestampFromUnixSeconds(newestMatchingTimestamp) +} + +async function fetchOptimizationsFromRedis(): Promise { + let keys: string[] + let rawPayloads: Array + + try { + keys = await readOptimizationKeys() + if (keys.length === 0) { + return null + } + + rawPayloads = await Promise.all(keys.map((key) => getClient().get(key))) + } catch (error) { + throw classifyError(error) + } + + const payloadRecords: OptimizationPayloadRecord[] = [] + for (const [index, rawPayload] of rawPayloads.entries()) { + if (!rawPayload) { + continue + } + + const normalizedRawPayload = typeof rawPayload === 'string' ? rawPayload : JSON.stringify(rawPayload) + payloadRecords.push({ + keyInfo: parseOptimizationKey(keys[index]), + rawPayload: normalizedRawPayload + }) + } + + const optimizations: VaultOptimizationRecord[] = [] + for (const [index, rawPayload] of rawPayloads.entries()) { + if (!rawPayload) { + continue + } + + const parsed = typeof rawPayload === 'string' ? JSON.parse(rawPayload) : rawPayload + const key = keys[index] + const keyInfo = parseOptimizationKey(key) + const normalizedRawPayload = typeof rawPayload === 'string' ? rawPayload : JSON.stringify(rawPayload) + const latestMatchedTimestampUtc = findLatestAliasTimestamp(keyInfo, normalizedRawPayload, payloadRecords) + const source: OptimizationSourceMeta = { + key, + chainId: keyInfo.chainId, + revision: keyInfo.revision, + isLatestAlias: keyInfo.isLatestAlias, + timestampUtc: keyInfo.revisionTimestampUtc, + latestMatchedTimestampUtc + } + + const parsedOptimizations = parseVaultOptimizations(parsed, `redis payload (${key})`) + for (const optimization of parsedOptimizations) { + optimizations.push({ + ...optimization, + source + }) + } + } + + return optimizations.length > 0 ? optimizations : null +} + +export async function readOptimizations(): Promise { + if (optimizationsCache && Date.now() < optimizationsCache.expiresAtMs) { + return optimizationsCache.value + } + + if (optimizationsInFlight) { + return optimizationsInFlight + } + + optimizationsInFlight = (async () => { + const value = await fetchOptimizationsFromRedis() + optimizationsCache = { + value, + expiresAtMs: Date.now() + LOCAL_CACHE_TTL_MS + } + return value + })() + + try { + return await optimizationsInFlight + } finally { + optimizationsInFlight = null + } +} + +export function findVaultOptimization( + optimizations: readonly T[], + vaultAddress: string +): T | undefined { + const normalizedAddress = vaultAddress.toLowerCase() + return optimizations.find((opt) => opt.vault.toLowerCase() === normalizedAddress) +} diff --git a/api/optimization/_lib/rpc.ts b/api/optimization/_lib/rpc.ts new file mode 100644 index 000000000..9ef7c3a21 --- /dev/null +++ b/api/optimization/_lib/rpc.ts @@ -0,0 +1,278 @@ +export interface RpcConfig { + primary: string + fallbacks: string[] +} + +const RPC_CONFIG: Record = { + 1: { + primary: 'https://ethereum-rpc.publicnode.com', + fallbacks: [ + 'https://1rpc.io/eth', + 'https://rpc.ankr.com/eth', + 'https://eth-mainnet.nodereal.io/v1/1659dfb40aa24bbb8153a677b98064d7' + ] + }, + 10: { + primary: 'https://optimism.public.blockpi.network/v1/rpc/public', + fallbacks: [ + 'https://1rpc.io/op', + 'https://optimism-public.nodies.app', + 'https://optimism-mainnet.public.blastapi.io' + ] + }, + 137: { + primary: 'https://polygon-bor-rpc.publicnode.com', + fallbacks: ['https://rpc.ankr.com/polygon', 'https://1rpc.io/matic', 'https://polygon-public.nodies.app'] + }, + 42161: { + primary: 'https://arbitrum-one.public.blastapi.io', + fallbacks: ['https://1rpc.io/arb', 'https://arbitrum-one-public.nodies.app', 'https://rpc.ankr.com/arbitrum'] + }, + 8453: { + primary: 'https://base-mainnet.public.blastapi.io', + fallbacks: [ + 'https://1rpc.io/base', + 'https://base-public.nodies.app', + 'https://base.public.blockpi.network/v1/rpc/public' + ] + }, + 250: { + primary: 'https://fantom-rpc.publicnode.com', + fallbacks: ['https://1rpc.io/ftm', 'https://fantom-public.nodies.app', 'https://fantom-mainnet.public.blastapi.io'] + } +} + +export function getRpcConfig(chainId: number): RpcConfig | undefined { + return RPC_CONFIG[chainId] +} + +export function getAllRpcEndpoints(chainId: number): string[] { + const config = RPC_CONFIG[chainId] + if (!config) return [] + return [config.primary, ...config.fallbacks] +} + +export function getRandomRpcEndpoint(chainId: number): string | undefined { + const endpoints = getAllRpcEndpoints(chainId) + if (endpoints.length === 0) return undefined + return endpoints[Math.floor(Math.random() * endpoints.length)] +} + +const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11' + +const VAULT_SELECTORS = { + totalAssets: '0x01e1d114', + strategies: '0x39ebf823' +} + +function encodeAddressParam(addr: string): string { + return addr.toLowerCase().replace('0x', '').padStart(64, '0') +} + +function encodeMulticallAggregate(calls: Array<{ target: string; callData: string }>): string { + const selector = '252dba42' + const numCalls = calls.length + + const arrayOffset = '0000000000000000000000000000000000000000000000000000000000000020' + const arrayLength = numCalls.toString(16).padStart(64, '0') + + let tupleOffsets = '' + let tupleData = '' + + let currentOffset = 32 * numCalls + + for (const call of calls) { + tupleOffsets += currentOffset.toString(16).padStart(64, '0') + + const target = call.target.toLowerCase().replace('0x', '').padStart(64, '0') + const callData = call.callData.replace(/^0x/, '') + const callDataLen = callData.length / 2 + const paddedLen = Math.ceil(callDataLen / 32) * 32 + const paddedData = callData.padEnd(paddedLen * 2, '0') + + const bytesOffset = '0000000000000000000000000000000000000000000000000000000000000040' + const lengthHex = callDataLen.toString(16).padStart(64, '0') + + tupleData += target + bytesOffset + lengthHex + paddedData + + currentOffset += 64 + 32 + paddedLen + } + + return '0x' + selector + arrayOffset + arrayLength + tupleOffsets + tupleData +} + +function decodeMulticallAggregateResult(hex: string): { blockNumber: bigint; results: string[] } { + const clean = hex.replace(/^0x/, '') + + const blockNumber = BigInt('0x' + clean.slice(0, 64)) + + const returnDataOffset = Number(BigInt('0x' + clean.slice(64, 128))) + const returnDataStart = returnDataOffset * 2 + const arrayLength = Number(BigInt('0x' + clean.slice(returnDataStart, returnDataStart + 64))) + + const results: string[] = [] + + for (let i = 0; i < arrayLength; i++) { + const offsetPos = returnDataStart + 64 + i * 64 + const elementOffset = Number(BigInt('0x' + clean.slice(offsetPos, offsetPos + 64))) + const elementStart = returnDataStart + elementOffset * 2 + + const bytesLength = Number(BigInt('0x' + clean.slice(elementStart, elementStart + 64))) + const bytesData = clean.slice(elementStart + 64, elementStart + 64 + bytesLength * 2) + results.push('0x' + bytesData) + } + + return { blockNumber, results } +} + +interface JsonRpcResponse { + jsonrpc: string + id: number + result?: T + error?: { code: number; message: string } +} + +async function jsonRpcCall(endpoint: string, method: string, params: unknown[]): Promise { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + }) + + if (!response.ok) { + throw new Error(`RPC HTTP ${response.status}`) + } + + const data = (await response.json()) as JsonRpcResponse + if (data.error) { + throw new Error(`RPC error ${data.error.code}: ${data.error.message}`) + } + + if (data.result === undefined) { + throw new Error('RPC returned undefined result') + } + + return data.result +} + +function decodeUint256(hex: string): bigint { + return BigInt(hex) +} + +function extractCurrentDebtFromStrategiesResult(hex: string): bigint { + const clean = hex.replace(/^0x/, '') + const currentDebtHex = '0x' + clean.slice(128, 192) + return decodeUint256(currentDebtHex) +} + +async function fetchVaultStateViaMulticall( + endpoint: string, + vaultAddress: string, + strategyAddresses: string[] +): Promise<{ totalAssets: bigint; strategyDebts: Map }> { + const calls = [ + { target: vaultAddress, callData: VAULT_SELECTORS.totalAssets }, + ...strategyAddresses.map((addr) => ({ + target: vaultAddress, + callData: VAULT_SELECTORS.strategies + encodeAddressParam(addr) + })) + ] + + const calldata = encodeMulticallAggregate(calls) + const result = await jsonRpcCall(endpoint, 'eth_call', [{ to: MULTICALL3_ADDRESS, data: calldata }, 'latest']) + + const decoded = decodeMulticallAggregateResult(result) + + if (decoded.results.length !== calls.length) { + throw new Error(`Multicall returned ${decoded.results.length} results, expected ${calls.length}`) + } + + const totalAssets = decodeUint256(decoded.results[0]) + const strategyDebts = new Map() + + for (let i = 0; i < strategyAddresses.length; i++) { + const debt = extractCurrentDebtFromStrategiesResult(decoded.results[i + 1]) + strategyDebts.set(strategyAddresses[i].toLowerCase(), debt) + } + + return { totalAssets, strategyDebts } +} + +async function fetchVaultStateSequential( + endpoint: string, + vaultAddress: string, + strategyAddresses: string[] +): Promise<{ totalAssets: bigint; strategyDebts: Map }> { + const totalAssetsResult = await jsonRpcCall(endpoint, 'eth_call', [ + { to: vaultAddress, data: VAULT_SELECTORS.totalAssets }, + 'latest' + ]) + const totalAssets = decodeUint256(totalAssetsResult) + + const strategyDebts = new Map() + for (const strategy of strategyAddresses) { + const data = VAULT_SELECTORS.strategies + encodeAddressParam(strategy) + const result = await jsonRpcCall(endpoint, 'eth_call', [{ to: vaultAddress, data }, 'latest']) + strategyDebts.set(strategy.toLowerCase(), extractCurrentDebtFromStrategiesResult(result)) + } + + return { totalAssets, strategyDebts } +} + +export async function fetchVaultOnChainState( + chainId: number, + vaultAddress: string, + strategyAddresses: string[] +): Promise<{ + totalAssets: bigint + strategyDebts: Map + unallocatedBps: number +}> { + const endpoints = getAllRpcEndpoints(chainId) + if (endpoints.length === 0) { + throw new Error(`No RPC endpoints configured for chain ${chainId}`) + } + + let lastError: Error | undefined + + for (const endpoint of endpoints) { + try { + const { totalAssets, strategyDebts } = await fetchVaultStateViaMulticall( + endpoint, + vaultAddress, + strategyAddresses + ) + return computeUnallocated(totalAssets, strategyDebts) + } catch { + try { + const { totalAssets, strategyDebts } = await fetchVaultStateSequential( + endpoint, + vaultAddress, + strategyAddresses + ) + return computeUnallocated(totalAssets, strategyDebts) + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)) + } + } + } + + throw lastError ?? new Error('All RPC endpoints failed') +} + +function computeUnallocated( + totalAssets: bigint, + strategyDebts: Map +): { totalAssets: bigint; strategyDebts: Map; unallocatedBps: number } { + const totalAllocated = Array.from(strategyDebts.values()).reduce((sum, debt) => sum + debt, BigInt(0)) + + let unallocatedBps: number + if (totalAssets > BigInt(0)) { + const unallocated = totalAssets - totalAllocated + unallocatedBps = Number((unallocated * BigInt(10000)) / totalAssets) + } else { + unallocatedBps = 10000 + } + + return { totalAssets, strategyDebts, unallocatedBps } +} diff --git a/api/optimization/_lib/schema.ts b/api/optimization/_lib/schema.ts new file mode 100644 index 000000000..0cb66385b --- /dev/null +++ b/api/optimization/_lib/schema.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +const AddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address') + +const StrategyDebtRatioSchema = z.object({ + strategy: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address'), + name: z.string().min(1, 'Strategy name required').optional(), + targetRatio: z.number().int().min(0).max(10000), + currentRatio: z.number().int().min(0).max(10000), + currentApr: z.number().int().min(0).optional(), + targetApr: z.number().int().min(0).optional() +}) + +const VaultOptimizationSchema = z.object({ + vault: AddressSchema, + strategyDebtRatios: z.array(StrategyDebtRatioSchema), + currentApr: z.number().int().min(0), + proposedApr: z.number().int().min(0), + explain: z.string() +}) + +const VaultOptimizationsSchema = z.array(VaultOptimizationSchema) + +export type StrategyDebtRatio = z.infer +export type VaultOptimization = z.infer + +export function parseVaultOptimizations(data: unknown, sourceLabel = 'optimization payload'): VaultOptimization[] { + const result = VaultOptimizationsSchema.safeParse(data) + + if (!result.success) { + const firstError = result.error.issues[0] + throw new Error(`Invalid ${sourceLabel}: ${firstError.path.join('.')} - ${firstError.message}`) + } + + return result.data +} diff --git a/api/optimization/alignment.ts b/api/optimization/alignment.ts new file mode 100644 index 000000000..2d39dd713 --- /dev/null +++ b/api/optimization/alignment.ts @@ -0,0 +1,67 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { getVaultDecimals } from './_lib/assetLogos' +import { fetchAlignedEvents } from './_lib/envio' +import { parseExplainMetadata } from './_lib/explain-parse' +import { findVaultOptimization, readOptimizations } from './_lib/redis' + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + const vault = req.query.vault as string | undefined + if (!vault) { + return res.status(400).json({ error: 'vault parameter required' }) + } + + const envioUrl = process.env.ENVIO_GRAPHQL_URL + if (!envioUrl) { + return res.status(503).json({ error: 'ENVIO_GRAPHQL_URL not configured' }) + } + + try { + const optimizations = await readOptimizations() + if (!optimizations || optimizations.length === 0) { + return res.status(404).json({ error: 'No optimization data available' }) + } + + const optimization = findVaultOptimization(optimizations, vault) + if (!optimization) { + return res.status(404).json({ error: `Vault not found: ${vault}` }) + } + + let chainId = optimization.source.chainId + if (!chainId) { + const metadata = parseExplainMetadata(optimization.explain) + chainId = metadata.chainId + } + if (!chainId) { + return res.status(400).json({ error: 'Could not determine chain ID for vault' }) + } + + const timestampStr = optimization.source.latestMatchedTimestampUtc ?? optimization.source.timestampUtc + if (!timestampStr) { + return res.status(400).json({ error: 'No timestamp available for vault snapshot' }) + } + const fromTs = Math.floor(new Date(timestampStr.replace(' UTC', 'Z').replace(' ', 'T')).getTime() / 1000) + const numStrategies = optimization.strategyDebtRatios.length + const toTs = fromTs + numStrategies * 10 * 60 * 2 + + const decimals = getVaultDecimals(vault) + + const events = await fetchAlignedEvents( + envioUrl, + vault, + chainId, + optimization.strategyDebtRatios, + fromTs, + toTs, + decimals + ) + + return res.status(200).setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=30').json(events) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return res.status(500).json({ error: message }) + } +} diff --git a/api/optimization/change.ts b/api/optimization/change.ts new file mode 100644 index 000000000..94afe4977 --- /dev/null +++ b/api/optimization/change.ts @@ -0,0 +1,65 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { + findVaultOptimization, + isRedisAuthenticationError, + isRedisConnectivityError, + readOptimizations +} from './_lib/redis' + +const CACHE_CONTROL = 'public, s-maxage=600, stale-while-revalidate=60' + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + return res.status(204).send(null) + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + const optimizations = await readOptimizations() + if (!optimizations || optimizations.length === 0) { + return res.status(404).json({ error: 'No optimization data available' }) + } + + const requestedVault = req.query.vault as string | undefined + if (requestedVault) { + const selected = findVaultOptimization(optimizations, requestedVault) + if (!selected) { + return res.status(404).json({ error: `Vault not found in optimization payload: ${requestedVault}` }) + } + + return res + .status(200) + .setHeader('Cache-Control', CACHE_CONTROL) + .setHeader('Access-Control-Allow-Origin', '*') + .json(selected) + } + + return res + .status(200) + .setHeader('Cache-Control', CACHE_CONTROL) + .setHeader('Access-Control-Allow-Origin', '*') + .json(optimizations) + } catch (error) { + if (isRedisAuthenticationError(error)) { + return res.status(500).json({ + error: + 'Backend Redis authentication failed. Check UPSTASH_REDIS_REST_USERNAME and UPSTASH_REDIS_REST_TOKEN credentials.' + }) + } + + if (isRedisConnectivityError(error)) { + return res.status(503).json({ + error: 'Backend connectivity unavailable. Unable to access Redis.' + }) + } + + const message = error instanceof Error ? error.message : String(error) + return res.status(500).json({ error: message }) + } +} diff --git a/api/optimization/vault-state.ts b/api/optimization/vault-state.ts new file mode 100644 index 000000000..a0496a900 --- /dev/null +++ b/api/optimization/vault-state.ts @@ -0,0 +1,45 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { fetchVaultOnChainState } from './_lib/rpc' + +const CACHE_CONTROL = 'public, s-maxage=60, stale-while-revalidate=30' + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + const payload = req.body as Record + const vault = typeof payload.vault === 'string' ? payload.vault : null + const chainId = typeof payload.chainId === 'number' ? payload.chainId : null + const strategies = Array.isArray(payload.strategies) + ? payload.strategies.filter((s: unknown): s is string => typeof s === 'string') + : [] + + if (!vault || !/^0x[a-fA-F0-9]{40}$/.test(vault)) { + return res.status(400).json({ error: 'Invalid vault address' }) + } + if (chainId === null || !Number.isFinite(chainId)) { + return res.status(400).json({ error: 'Invalid chainId' }) + } + if (strategies.length === 0) { + return res.status(400).json({ error: 'No strategy addresses provided' }) + } + + try { + const state = await fetchVaultOnChainState(chainId, vault, strategies) + + const strategyDebts: Record = {} + for (const [addr, debt] of state.strategyDebts) { + strategyDebts[addr] = debt.toString() + } + + return res.status(200).setHeader('Cache-Control', CACHE_CONTROL).json({ + totalAssets: state.totalAssets.toString(), + strategyDebts, + unallocatedBps: state.unallocatedBps + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return res.status(503).json({ error: message }) + } +} diff --git a/api/server.ts b/api/server.ts index 3bd6c1c01..fd52a9e1f 100644 --- a/api/server.ts +++ b/api/server.ts @@ -5,6 +5,16 @@ import type { TTenderlyRevertRequest, TTenderlySnapshotRequest } from '../src/components/shared/types/tenderly' +import { getVaultDecimals } from './optimization/_lib/assetLogos' +import { fetchAlignedEvents } from './optimization/_lib/envio' +import { parseExplainMetadata } from './optimization/_lib/explain-parse' +import { + findVaultOptimization, + isRedisAuthenticationError, + isRedisConnectivityError, + readOptimizations +} from './optimization/_lib/redis' +import { fetchVaultOnChainState } from './optimization/_lib/rpc' import { buildTenderlyPanelStatus, buildTenderlyRevertResponse, @@ -351,6 +361,180 @@ async function handleEnsoBalances(req: Request): Promise { } } +const CHANGE_CACHE_CONTROL = 'public, s-maxage=600, stale-while-revalidate=60' +const CHANGE_CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type' +} + +async function handleOptimizationChange(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: CHANGE_CORS_HEADERS }) + } + + if (req.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + const optimizations = await readOptimizations() + if (!optimizations || optimizations.length === 0) { + return Response.json({ error: 'No optimization data available' }, { status: 404 }) + } + + const url = new URL(req.url) + const requestedVault = url.searchParams.get('vault') + if (requestedVault) { + const selected = findVaultOptimization(optimizations, requestedVault) + if (!selected) { + return Response.json({ error: `Vault not found in optimization payload: ${requestedVault}` }, { status: 404 }) + } + + return Response.json(selected, { + headers: { ...CHANGE_CORS_HEADERS, 'Cache-Control': CHANGE_CACHE_CONTROL } + }) + } + + return Response.json(optimizations, { + headers: { ...CHANGE_CORS_HEADERS, 'Cache-Control': CHANGE_CACHE_CONTROL } + }) + } catch (error) { + if (isRedisAuthenticationError(error)) { + return Response.json( + { + error: + 'Backend Redis authentication failed. Check UPSTASH_REDIS_REST_USERNAME and UPSTASH_REDIS_REST_TOKEN credentials.' + }, + { status: 500 } + ) + } + + if (isRedisConnectivityError(error)) { + return Response.json({ error: 'Backend connectivity unavailable. Unable to access Redis.' }, { status: 503 }) + } + + const message = error instanceof Error ? error.message : String(error) + return Response.json({ error: message }, { status: 500 }) + } +} + +async function handleOptimizationAlignment(req: Request): Promise { + if (req.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + const url = new URL(req.url) + const vault = url.searchParams.get('vault') + if (!vault) { + return Response.json({ error: 'vault parameter required' }, { status: 400 }) + } + + const envioUrl = process.env.ENVIO_GRAPHQL_URL + if (!envioUrl) { + return Response.json({ error: 'ENVIO_GRAPHQL_URL not configured' }, { status: 503 }) + } + + try { + const optimizations = await readOptimizations() + if (!optimizations || optimizations.length === 0) { + return Response.json({ error: 'No optimization data available' }, { status: 404 }) + } + + const optimization = findVaultOptimization(optimizations, vault) + if (!optimization) { + return Response.json({ error: `Vault not found: ${vault}` }, { status: 404 }) + } + + let chainId = optimization.source.chainId + if (!chainId) { + const metadata = parseExplainMetadata(optimization.explain) + chainId = metadata.chainId + } + if (!chainId) { + return Response.json({ error: 'Could not determine chain ID for vault' }, { status: 400 }) + } + + const timestampStr = optimization.source.latestMatchedTimestampUtc ?? optimization.source.timestampUtc + if (!timestampStr) { + return Response.json({ error: 'No timestamp available for vault snapshot' }, { status: 400 }) + } + const fromTs = Math.floor(new Date(timestampStr.replace(' UTC', 'Z').replace(' ', 'T')).getTime() / 1000) + const numStrategies = optimization.strategyDebtRatios.length + const toTs = fromTs + numStrategies * 10 * 60 * 2 + + const decimals = getVaultDecimals(vault) + + const events = await fetchAlignedEvents( + envioUrl, + vault, + chainId, + optimization.strategyDebtRatios, + fromTs, + toTs, + decimals + ) + + return Response.json(events, { + headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30' } + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return Response.json({ error: message }, { status: 500 }) + } +} + +async function handleOptimizationVaultState(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + let body: unknown + try { + body = await req.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const payload = body as Record + const vault = typeof payload.vault === 'string' ? payload.vault : null + const chainId = typeof payload.chainId === 'number' ? payload.chainId : null + const strategies = Array.isArray(payload.strategies) + ? payload.strategies.filter((s: unknown): s is string => typeof s === 'string') + : [] + + if (!vault || !/^0x[a-fA-F0-9]{40}$/.test(vault)) { + return Response.json({ error: 'Invalid vault address' }, { status: 400 }) + } + if (chainId === null || !Number.isFinite(chainId)) { + return Response.json({ error: 'Invalid chainId' }, { status: 400 }) + } + if (strategies.length === 0) { + return Response.json({ error: 'No strategy addresses provided' }, { status: 400 }) + } + + try { + const state = await fetchVaultOnChainState(chainId, vault, strategies) + + const strategyDebts: Record = {} + for (const [addr, debt] of state.strategyDebts) { + strategyDebts[addr] = debt.toString() + } + + return Response.json( + { + totalAssets: state.totalAssets.toString(), + strategyDebts, + unallocatedBps: state.unallocatedBps + }, + { headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30' } } + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return Response.json({ error: message }, { status: 503 }) + } +} + serve({ async fetch(req, server) { const url = new URL(req.url) @@ -407,6 +591,18 @@ serve({ return handleTenderlyFund(req) } + if (url.pathname === '/api/optimization/change') { + return handleOptimizationChange(req) + } + + if (url.pathname === '/api/optimization/alignment') { + return handleOptimizationAlignment(req) + } + + if (url.pathname === '/api/optimization/vault-state') { + return handleOptimizationVaultState(req) + } + return new Response('Not found', { status: 404 }) }, port: API_SERVER_PORT diff --git a/api/tsconfig.json b/api/tsconfig.json index 6d1534274..a2e462520 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "module": "commonjs", "moduleResolution": "node", - "lib": ["ES2020"], + "lib": ["ES2022"], "allowJs": true, "strict": true, "esModuleInterop": true, diff --git a/bun.lock b/bun.lock index eb500306d..9fa1f35a2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "yearnfi", @@ -10,6 +11,7 @@ "@react-hookz/web": "24.0.4", "@tanstack/react-query": "5.90.21", "@tanstack/react-virtual": "3.13.18", + "@upstash/redis": "^1.37.0", "cross-fetch": "4.1.0", "ethers": "5.7.2", "framer-motion": "12.34.0", @@ -584,6 +586,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@upstash/redis": ["@upstash/redis@1.37.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw=="], + "@vanilla-extract/css": ["@vanilla-extract/css@1.17.3", "", { "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.8", "css-what": "^6.1.0", "cssesc": "^3.0.0", "csstype": "^3.0.7", "dedent": "^1.5.3", "deep-object-diff": "^1.1.9", "deepmerge": "^4.2.2", "lru-cache": "^10.4.3", "media-query-parser": "^2.0.2", "modern-ahocorasick": "^1.0.0", "picocolors": "^1.0.0" } }, "sha512-jHivr1UPoJTX5Uel4AZSOwrCf4mO42LcdmnhJtUxZaRWhW4FviFbIfs0moAWWld7GOT+2XnuVZjjA/K32uUnMQ=="], "@vanilla-extract/dynamic": ["@vanilla-extract/dynamic@2.1.4", "", { "dependencies": { "@vanilla-extract/private": "^1.0.8" } }, "sha512-7+Ot7VlP3cIzhJnTsY/kBtNs21s0YD7WI1rKJJKYP56BkbDxi/wrQUWMGEczKPUDkJuFcvbye+E2ub1u/mHH9w=="], diff --git a/package.json b/package.json index 925a91422..935571881 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@react-hookz/web": "24.0.4", "@tanstack/react-query": "5.90.21", "@tanstack/react-virtual": "3.13.18", + "@upstash/redis": "^1.37.0", "cross-fetch": "4.1.0", "ethers": "5.7.2", "framer-motion": "12.34.0",