import type { Address } from 'viem' import * as z from 'zod/mini' import * as Schema from './Schema.js' import * as Store from './Store.js' import type * as Tidx from './Tidx.js' import * as Ttl from './Ttl.js' import * as Value from './Value.js' import type * as Viem from './Viem.js' const migratedSignature = 'event EngineMigrated(address indexed oldEngine,address indexed newEngine,uint256 oldEngineShares,uint256 assetsMoved,uint256 newEngineShares,uint256 totalEarnShares,uint256 anchorEngineShares,uint256 anchorEarnShares)' /** Identifier of the rate calculation, versioned so a change is visible to callers. */ const methodology = 'share-price-growth-annualized:v1' as const /** Width of the UTC bucket every measurement is anchored to. */ const bucketWidth = Ttl.minutes(15) /** Withheld from the anchor so an unconfirmed head block cannot move a boundary. */ const confirmationDelay = Ttl.seconds(30) /** Default window, used when the caller does not select one. */ const defaultWindow = '7d' /** * Selectable measurement windows. A closed set bounds both the rate cache and * the historical read volume: every distinct window is its own cache entry and * its own pair of boundary reads. */ const windows = ['1h', '1d', '7d', '30d'] as const /** Length of every selectable window in whole hours. */ const windowHours = { '1h': 1, '1d': 24, '7d': 24 * 7, '30d': 24 * 30, } as const satisfies Record /** Longest selectable window, which also bounds the migration scan. */ const maxWindowHours = Math.max(...Object.values(windowHours)) /** * Row cap on the migration scan. Engine migrations are rare, so a scan that * fills the cap cannot be trusted to be complete and resets every window. */ const migrationScanLimit = 500 /** Decimal places of the returned ratio, matching the API's value precision. */ const netPrecision = 6 /** `Number.toFixed` switches to exponential notation here, which the ratio contract forbids. */ const netCeiling = 1e21 /** * Shares quoted at both boundaries. Large so the assets-per-share ratio is not * quantized by asset base units; the amount cancels in the ratio. */ const quotedShares = 10n ** 18n const secondsPerYear = 365 * 24 * 60 * 60 /** Zod schemas owned by the Earn rate resolver. */ export namespace schema { /** Annualized share-price growth over one measurement window. */ export const Apy = z .strictObject({ asOf: z.iso .datetime() .check( z.describe('UTC instant the rate was measured to, rounded to a 15-minute bucket.'), z.meta({ examples: ['2026-07-21T09:00:00.000Z'] }), ), methodology: z .literal(methodology) .check( z.describe('Identifier of the calculation that produced this rate.'), z.meta({ examples: [methodology] }), ), net: z .string() .check( z.regex(/^-?\d+(\.\d+)?$/), z.describe('Annualized fee-aware share-price growth, as a decimal ratio.'), z.meta({ examples: ['0.041200'] }), ), window: z .enum(windows) .check(z.describe('Window this rate covers.'), z.meta({ examples: [defaultWindow] })), }) .check(z.describe('Annualized share-price growth over one measurement window.')) /** * Measurement window selected by a request. Stays absent when omitted so a * caller who never asked for a rate is distinguishable from one who selected * the default window. */ export const Window = z .optional(z.enum(windows)) .check( z.describe( 'Rate measurement window. Selecting a window includes `apy`, which measures `7d` on its own.', ), z.meta({ examples: [defaultWindow] }), ) } /** Annualized share-price growth over one measurement window. */ export type Apy = z.output /** Selectable rate measurement window. */ export type Window = (typeof windows)[number] /** Returns the confirmed 15-minute UTC bucket rates are measured to. */ export function asOf(): string { const anchor = Math.floor((Date.now() - confirmationDelay) / bucketWidth) * bucketWidth return new Date(anchor).toISOString() } /** * Resolves the annualized rate of every vault on a page, keyed by lowercase * vault address. A vault maps to `null` when its rate cannot be measured. */ export async function resolve(options: resolve.Options): Promise> { const { chainId, store } = options const window = options.window ?? defaultWindow const vaults = [...new Set(options.vaults.map((vault) => Schema.Address.parse(vault)))] if (vaults.length === 0) return new Map() const measuredAt = asOf() let pending: Promise | undefined // Boundary blocks, the migration scan, and both quote batches load once per // page, and only when a vault misses its cached rate. Rate enrichment never // fails a page, so an unreadable window leaves every vault unmeasured. const measure = () => (pending ??= measurePage({ ...options, measuredAt, vaults, window }).catch(() => undefined)) const entries = await Promise.all( vaults.map(async (vault) => { const apy = await Store.memoize( async () => { const measured = await measure() const net = measured?.rates.get(vault) if (net === undefined || measured === undefined) return null // Labelled with the boundaries' own instant: cached boundaries can // predate this request's bucket, and the label must match the prices. return { asOf: measured.measuredAt, methodology, net, window } }, { // Keyed without the bucket so the entry is revisited and lazily // evicted; `fx:v1:` sets the same stable-key precedent. The // cached value keeps the `asOf` it was measured at. key: `earn-apy:v1:${chainId}:${vault}:${window}`, store, ttl: bucketWidth, }, ) return [vault, apy] as const }), ) return new Map(entries) } export declare namespace resolve { /** Options for resolving a page of Earn vault rates. */ type Options = { /** Chain containing the vaults. */ chainId: number /** Resolves the RPC client used for historical share-price quotes. */ getClient: Viem.GetClient /** Resolves the indexer client used for block and migration history. */ getTidx: Tidx.GetClient /** Cache backing measured rates. */ store: Store.Store /** Vault addresses appearing on the page. */ vaults: readonly Address[] /** Canonical measurement window, defaulting to `7d`. */ window?: Window | undefined } } /** * Annualizes share-price growth between two measured boundaries. Returns `null` * for a degenerate window, a zero starting price, or growth too extreme to * express as a decimal ratio. */ export function annualize(options: annualize.Options): string | null { const { end, start } = options const elapsed = end.timestamp - start.timestamp if (elapsed <= 0 || start.price <= 0n) return null const net = (Number(end.price) / Number(start.price)) ** (secondsPerYear / elapsed) - 1 if (!Number.isFinite(net) || Math.abs(net) >= netCeiling) return null return net.toFixed(netPrecision) } export declare namespace annualize { /** One measured boundary of the window. */ type Boundary = { /** Assets quoted for the shared share amount, in asset base units. */ price: bigint /** Block timestamp, in seconds. */ timestamp: number } /** Options for annualizing measured share-price growth. */ type Options = { /** Newer boundary of the window. */ end: Boundary /** Older boundary of the window. */ start: Boundary } } type Block = { number: bigint timestamp: number } type Boundaries = { end: Block /** Instant these boundaries were resolved for, which may predate the caller's. */ measuredAt: string start: Block } type Migration = { blockNumber: bigint vault: string } async function measurePage(options: measurePage.Options): Promise { const endAt = Date.parse(options.measuredAt) const boundaries = await boundaryBlocks({ ...options, endAt, startAt: endAt - windowHours[options.window] * Ttl.hour(1), }) if (!boundaries) return undefined const { end, measuredAt, start } = boundaries const client = options.getClient(options.chainId) const quote = (blockNumber: bigint, vault: Address) => client.earn.getRedeemQuote({ blockNumber, shareAmount: quotedShares, vault }).then( (price) => price, () => undefined, ) // The migration scan is independent of historical quotes, so keep its TIDX // round trip off the RPC critical path. const migrations = recentMigrations(options).then( (value) => ({ status: 'fulfilled', value }) as const, (reason: unknown) => ({ reason, status: 'rejected' }) as const, ) const rates = new Map() await Promise.all( options.vaults.map(async (vault) => { // Concurrent quotes collapse into one deployless multicall per block. const [startPrice, endPrice] = await Promise.all([ quote(start.number, vault), quote(end.number, vault), ]) if (startPrice === undefined || endPrice === undefined) return const net = annualize({ end: { price: endPrice, timestamp: end.timestamp }, start: { price: startPrice, timestamp: start.timestamp }, }) if (net !== null) rates.set(vault, net) }), ) if (rates.size === 0) return { measuredAt, rates } const migrationResult = await migrations // A migration inside the window bridges two different yield engines, so the // window resets instead of reporting growth across the swap. if (migrationResult.status === 'rejected') throw migrationResult.reason if (!migrationResult.value) return undefined for (const { blockNumber, vault } of migrationResult.value) if (blockNumber > start.number && blockNumber <= end.number) rates.delete(vault) return { measuredAt, rates } } /** Rates for one page, labelled with the instant their prices were read at. */ type Measurement = { measuredAt: string rates: ReadonlyMap } declare namespace measurePage { type Options = resolve.Options & { /** Anchor instant the window ends at. */ measuredAt: string /** Canonical measurement window, resolved from the request or the default. */ window: Window } } /** * Resolves the last indexed block at or before each end of the window. Returns * undefined when either boundary is missing, both land on one block, or the * indexer lags a boundary. */ async function boundaryBlocks(options: boundaryBlocks.Options): Promise { const { chainId, endAt, startAt, window } = options const endInstant = new Date(endAt).toISOString() const startInstant = new Date(startAt).toISOString() const measured = await Store.memoize( async () => { // `blocks` indexes every block, empty ones included, so the last row at or // before an instant is the exact boundary. Both boundaries share one // request because the indexer sheds load by request count, not rows read. const result = await options.getTidx(chainId).fetch({ chainId, query: ` (SELECT num, timestamp FROM blocks WHERE timestamp <= '${endInstant}' ORDER BY timestamp DESC, num DESC LIMIT 1) UNION ALL (SELECT num, timestamp FROM blocks WHERE timestamp <= '${startInstant}' ORDER BY timestamp DESC, num DESC LIMIT 1) ` as string, }) const rows = result.rows.flatMap((row) => { const number = Value.toIntegerString(row['num']) const timestamp = Value.toIsoDateTime(row['timestamp']) return number === undefined || timestamp === undefined ? [] : [{ number, timestamp: Math.floor(Date.parse(timestamp) / 1000) }] }) // The instants travel with the rows so a hit from an earlier bucket is // judged, and labelled, against the instants it was resolved for. return { endAt, rows, startAt } }, { key: `earn-apy:v1:${chainId}:blocks:${window}`, store: options.store, ttl: bucketWidth, }, ) // The window start precedes indexed history whenever a boundary is missing; // the newer boundary always resolves once the older one does. const [end, start] = [...measured.rows].sort((a, b) => BigInt(a.number) < BigInt(b.number) ? 1 : -1, ) if (!end || !start || end.number === start.number) return undefined // A boundary further behind its instant than one bucket means the indexer is // lagging, and the measured elapsed time would not match the window. if (measured.endAt - end.timestamp * 1000 > bucketWidth) return undefined if (measured.startAt - start.timestamp * 1000 > bucketWidth) return undefined return { end: { number: BigInt(end.number), timestamp: end.timestamp }, measuredAt: new Date(measured.endAt).toISOString(), start: { number: BigInt(start.number), timestamp: start.timestamp }, } } declare namespace boundaryBlocks { type Options = Pick & { /** Instant the window ends at, in milliseconds. */ endAt: number /** Instant the window starts at, in milliseconds. */ startAt: number /** Window these boundaries span, used as the cache identity. */ window: Window } } /** * Engine migrations indexed over the longest selectable window, so one scan * serves every window and page anchored to the same instant. Returns undefined * when the scan filled its row cap and a migration may be missing. */ async function recentMigrations( options: recentMigrations.Options, ): Promise { const { chainId, measuredAt } = options // One bucket of slack because this scan and the boundaries expire // independently; scanning further back only resets more windows. const since = new Date( Date.parse(measuredAt) - maxWindowHours * Ttl.hour(1) - bucketWidth, ).toISOString() const migrations = await Store.memoize( async () => { // The emitting `address` is the vault; page and window filtering happens // on the scanned rows so this stays one query per anchor. const result = await options.getTidx(chainId).fetch({ chainId, query: ` SELECT address, block_num FROM EngineMigrated WHERE block_timestamp > '${since}' AND block_timestamp <= '${measuredAt}' ORDER BY block_num DESC LIMIT ${migrationScanLimit + 1} ` as string, signatures: [migratedSignature], }) const rows: readonly Record[] = result.rows if (rows.length > migrationScanLimit) return null return rows.flatMap((row) => { const blockNumber = Value.toIntegerString(row['block_num']) const vault = Value.toText(row['address']) return blockNumber && vault ? [{ blockNumber, vault: vault.toLowerCase() }] : [] }) }, { key: `earn-apy:v1:${chainId}:migrations`, store: options.store, ttl: bucketWidth, }, ) if (!migrations) return undefined return migrations.map(({ blockNumber, vault }) => ({ blockNumber: BigInt(blockNumber), vault })) } declare namespace recentMigrations { type Options = Pick }