import { type Address, ContractFunctionExecutionError, ContractFunctionRevertedError, ContractFunctionZeroDataError, } from 'viem' 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' /** Only interval supported by historical Earn share prices. */ export const interval = 'day' as const /** Maximum observations returned by one historical share-price request. */ export const maxPoints = 31 const maxShareDecimals = 77 // The TIDX proxy rejects deeper nested UNION ALL probes. const tidxChunkSize = 3 const tidxWaveSize = 4 /** Reads daily historical share prices for one Earn vault. */ export async function resolve(options: resolve.Options): Promise { const shareAmount = quoteShareAmount({ shareDecimals: options.shareDecimals }) const requested = timestamps({ from: options.from, to: options.to }) const blocks = await getBlocks(options.getTidx(options.chainId), { timestamps: requested }) const client = options.getClient(options.chainId) const points = await Promise.all( blocks.map((block) => client.earn .getRedeemQuote({ blockNumber: BigInt(block.number), shareAmount, vault: options.vault, }) .then( (amount) => ({ amount, timestamp: block.requestedAt }), (error) => { if (isUnavailable(error)) return undefined throw error }, ), ), ) return points.flatMap((point) => (point === undefined ? [] : [point])) } export declare namespace resolve { /** Inputs selecting one Earn vault share-price series. */ type Options = { /** Chain containing the Earn vault. */ chainId: number /** First inclusive daily observation, as an ISO 8601 timestamp. */ from: string /** Resolves the chain RPC client used for historical quotes. */ getClient: Viem.GetClient /** Resolves the chain indexer client used for block boundaries. */ getTidx: Tidx.GetClient /** Decimal places used by the vault share token. */ shareDecimals: number /** Inclusive upper bound for daily observations, as an ISO 8601 timestamp. */ to: string /** Earn vault contract address. */ vault: Address } } /** Returns one whole share in base units when it fits in a `uint256`. */ export function quoteShareAmount(options: quoteShareAmount.Options): bigint { if (options.shareDecimals > maxShareDecimals) throw new UnsupportedShareDecimalsError(options.shareDecimals) return 10n ** BigInt(options.shareDecimals) } export declare namespace quoteShareAmount { /** Share-token metadata used to derive one whole share. */ type Options = { /** Decimal places used by the vault share token. */ shareDecimals: number } } /** One historical share-price observation. */ export type Point = { /** Assets returned for one whole vault share, in the asset token's base unit. */ amount: bigint /** Requested daily observation timestamp in ISO 8601 format. */ timestamp: string } /** Returns daily observation timestamps anchored to the requested start. */ export function timestamps(options: timestamps.Options): readonly string[] { const from = Date.parse(options.from) const to = Date.parse(options.to) return Array.from({ length: Math.floor((to - from) / Ttl.days(1)) + 1 }, (_, index) => new Date(from + index * Ttl.days(1)).toISOString(), ) } export declare namespace timestamps { /** Range covered by daily observations. */ type Options = { /** First inclusive daily observation, as an ISO 8601 timestamp. */ from: string /** Inclusive upper bound for daily observations, as an ISO 8601 timestamp. */ to: string } } /** Returns whether a historical quote is unavailable from contract execution. */ export function isUnavailable(error: unknown): boolean { if (!(error instanceof ContractFunctionExecutionError)) return false return Boolean( error.walk( (cause) => cause instanceof ContractFunctionRevertedError || cause instanceof ContractFunctionZeroDataError, ), ) } /** Resolves one indexed block at or before every requested observation. */ async function getBlocks(tidx: Tidx.Client, options: getBlocks.Options): Promise { const last = options.timestamps.at(-1) if (last) await assertCoverage(tidx, last) const chunks = Array.from( { length: Math.ceil(options.timestamps.length / tidxChunkSize) }, (_, index) => options.timestamps.slice(index * tidxChunkSize, (index + 1) * tidxChunkSize), ) const results: Block[][] = [] // Bound concurrent queries so long ranges do not fan out into a wide indexer burst. for (let index = 0; index < chunks.length; index += tidxWaveSize) { const wave = await Promise.allSettled( chunks.slice(index, index + tidxWaveSize).map(async (timestamps) => { const boundaries = timestamps.map((requestedAt) => { const timestamp = requestedAt.replace('T', ' ').replace(/Z$/, '') return ` SELECT 'boundary' AS kind, '${timestamp}' AS requested_at, toString(num) AS num, toString(timestamp) AS timestamp FROM ( SELECT num, timestamp FROM blocks WHERE timestamp <= '${timestamp}' ORDER BY timestamp DESC, num DESC LIMIT 1 ) ` }) const result = await tidx .fetch({ engine: 'clickhouse', // SAFETY: Zod restricts every timestamp to ISO 8601 before this function is called. query: ` ${boundaries.join('\nUNION ALL\n')} ` as string, }) .catch((cause) => { throw new TidxRequestError(cause) }) return parseBlocks({ rows: result.rows, timestamps }) }), ) const failure = wave.find((result) => result.status === 'rejected') if (failure) throw failure.reason results.push(...wave.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))) } return results.flat() } async function assertCoverage(tidx: Tidx.Client, timestamp: string): Promise { const result = await tidx .fetch({ engine: 'clickhouse', query: ` SELECT toString(timestamp) AS timestamp FROM blocks ORDER BY timestamp DESC, num DESC LIMIT 1 ` as string, }) .catch((cause) => { throw new TidxRequestError(cause) }) parseCoverage({ row: result.rows[0], timestamp }) } /** Validates that indexed block timestamps cover a requested observation. */ export function parseCoverage(options: parseCoverage.Options): void { const indexedAt = Value.toIsoDateTime(options.row?.timestamp) if (indexedAt === undefined) throw new MalformedResponseError('TIDX returned no indexed coverage timestamp.') if (Date.parse(indexedAt) < Date.parse(options.timestamp)) throw new IndexedCoverageError(options.timestamp) } export declare namespace parseCoverage { /** Indexed timestamp and requested observation to compare. */ type Options = { /** Latest indexed block by timestamp. */ row: Row | undefined /** Requested observation timestamp. */ timestamp: string } } declare namespace getBlocks { /** Daily observations that need indexed blocks. */ type Options = { timestamps: readonly string[] } } type Block = { /** Indexed block number used for the historical quote. */ number: number /** Requested observation timestamp represented by this block. */ requestedAt: string /** Actual timestamp of the indexed block. */ timestamp: string } /** Decodes daily historical boundaries from TIDX rows. */ export function parseBlocks(options: parseBlocks.Options) { const boundaries = new Map() for (const row of options.rows) { const number = Value.toNumber(row.num) const requestedAt = Value.toIsoDateTime(row.requested_at) const timestamp = Value.toIsoDateTime(row.timestamp) if ( row.kind !== 'boundary' || number === undefined || !Number.isSafeInteger(number) || number < 0 || requestedAt === undefined || timestamp === undefined ) throw new MalformedResponseError('TIDX returned a malformed share-price boundary.') boundaries.set(requestedAt, { number, requestedAt, timestamp }) } return options.timestamps.map((requestedAt) => { const block = boundaries.get(requestedAt) if (!block) throw new IndexedBlockNotFoundError(requestedAt) return block }) } export declare namespace parseBlocks { /** Indexed rows and requested daily observations. */ type Options = { /** Rows containing the requested boundaries. */ rows: readonly Row[] /** Observation timestamps represented by the supplied boundary rows. */ timestamps: readonly string[] } } type Row = { kind?: unknown num?: unknown requested_at?: unknown timestamp?: unknown } /** Thrown when no indexed block exists for a requested observation. */ export class IndexedBlockNotFoundError extends Error { override name = 'EarnSharePrices.IndexedBlockNotFoundError' constructor(timestamp: string) { super(`No indexed block exists at or before ${timestamp}.`) } } /** Thrown when the indexer has not reached a requested observation. */ export class IndexedCoverageError extends Error { override name = 'EarnSharePrices.IndexedCoverageError' constructor(timestamp: string) { super(`Indexed history does not yet cover ${timestamp}.`) } } /** Thrown when TIDX returns malformed historical share-price data. */ export class MalformedResponseError extends Error { override name = 'EarnSharePrices.MalformedResponseError' constructor(message: string) { super(message) } } /** Thrown when a TIDX request fails before historical rows can be decoded. */ export class TidxRequestError extends Error { override name = 'EarnSharePrices.TidxRequestError' constructor(cause: unknown) { super('TIDX could not resolve historical share-price blocks.', { cause }) } } /** Thrown when one whole share cannot be represented as a `uint256`. */ export class UnsupportedShareDecimalsError extends Error { override name = 'EarnSharePrices.UnsupportedShareDecimalsError' constructor(decimals: number) { super(`Share tokens with ${decimals} decimals cannot represent one whole share as a uint256.`) } }