import { type Context, Hono } from 'hono' import { Value as core_Value } from 'ox' import { Abis, type Actions } from 'viem/tempo' import { type Chain, ContractFunctionExecutionError, ContractFunctionRevertedError, ContractFunctionZeroDataError, zeroAddress, } from 'viem' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Scope from '../../../Scope.js' import * as Db from '../../../db/Db.js' import * as core_EarnVaults from '../../../db/tables/earnVaults.js' import * as Auth from '../../../internal/Auth.js' import * as Cache from '../../../internal/Cache.js' import * as Cursor from '../../../internal/Cursor.js' import * as EarnRates from '../../../internal/EarnRates.js' import * as EarnVaults from '../../../internal/EarnVaults.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Store from '../../../internal/Store.js' import * as Tidx from '../../../internal/Tidx.js' import * as Timing from '../../../internal/Timing.js' import * as Ttl from '../../../internal/Ttl.js' import * as Value from '../../../internal/Value.js' import * as VerifiedTokens from '../../../internal/VerifiedTokens.js' import * as FxOracle from '../FxOracle.js' import * as Tokens from './tokens.js' import * as Valuation from './valuation.js' const deployedSignature = 'event EarnStackDeployed(address indexed earnVault,address indexed earnShare,address indexed earnFees,address engine,address asset,address owner,bytes32 deploymentId,address emergencyGuardian,address asyncJanitor,uint8 migrationMode,bytes32 earnShareSalt,bytes32 controlConfigHash,bytes32 feeConfigHash,bytes32 earnFeesSalt)' const depositedSignature = 'event Deposited(address indexed caller,address indexed receiver,uint256 assets,uint256 earnShares)' const redeemedSignature = 'event Redeemed(address indexed caller,address indexed receiver,uint256 earnShares,uint256 assets)' const withdrewExactSignature = 'event WithdrewExact(address indexed caller,address indexed receiver,uint256 assets,uint256 earnSharesBurned)' const redeemFinalizedSignature = 'event RedeemFinalized(bytes32 indexed requestId,address indexed receiver,uint256 earnShares,address asset,uint256 assets)' const redeemCancelledSignature = 'event RedeemCancelled(bytes32 indexed requestId,address indexed receiver,uint256 earnShares)' const redeemRequestedSignature = 'event RedeemRequested(bytes32 indexed requestId,address indexed requester,address indexed receiver,uint256 earnShares)' const venueSharesDepositedSignature = 'event VenueSharesDeposited(address indexed caller,address indexed receiver,uint256 requestedVenueShares,uint256 receivedVenueShares,uint256 earnShares)' const maxScannedDeployments = 250 const scanLimit = 50 // Concurrent metadata reads in one macrotask collapse into one deployless // multicall; an await between small chunks keeps each call under the RPC limit. const tokenReadChunkSize = 20 /** Block boundary that includes all indexed history. */ const beforeFirstBlockNumber = -1 /** Vault TVL is reported in USD only; the API exposes no denomination parameter. */ const tvlDenomination = 'USD' /** `Valuation.valuationFor` floors to 6 dp, so 6 is the precision a valued amount carries. */ const tvlDecimals = 6 /** Returns the smallest useful live-discovery batch. */ export function discoveryBatchLimit(options: discoveryBatchLimit.Options): number { const needed = options.limit + 1 - options.matches return Math.min(options.filtered ? needed * 2 : needed, options.remaining, scanLimit) } export declare namespace discoveryBatchLimit { /** Inputs controlling one discovery batch. */ type Options = { /** Whether filters can reject resolved candidates. */ filtered: boolean /** Requested response page size. */ limit: number /** Compatible matches already collected. */ matches: number /** Candidates remaining within the scan bound. */ remaining: number } } /** Returns whether API-key scopes grant read access to one private Zone. */ export function hasZoneAccess(options: hasZoneAccess.Options): boolean { const scopes = options.scopes ?? [] return scopes.includes(Scope.wildcard) || scopes.includes(`zone:${options.chainId}:read`) } export declare namespace hasZoneAccess { /** Inputs selecting one Zone against an optional API-key scope set. */ type Options = { /** Private Zone chain id. */ chainId: number /** API-key scopes, absent for every other principal. */ scopes?: readonly string[] | undefined } } /** Zod schemas owned by the public Earn-vault resource. */ export namespace schema { const capability = z .enum([ 'asyncRedeem', 'boundedRedeem', 'deposit', 'exactWithdraw', 'inKindDeposit', 'privateRouting', 'redeem', 'routerSwaps', ]) .check(z.describe('Vault capability required by the request.')) const engineType = z .enum(['erc4626', 'veda']) .check(z.describe('Inferred vault engine type.'), z.meta({ examples: ['erc4626'] })) const engine = Schema.describe( z.strictObject({ address: Schema.Address.check( z.describe('Current vault engine contract address.'), z.meta({ examples: ['0x59d27987bc7cc1521bba5f688a170c65840cbf43'] }), ), type: z .nullable(engineType) .check(z.describe('Inferred engine type, or null when it is unknown.')), venue: z .nullable( Schema.Address.check( z.describe('Yield venue contract bound to the engine.'), z.meta({ examples: ['0x9a044ae05e5e6290dcf56afd69548565e957a626'] }), ), ) .check(z.describe('Yield venue, or null when the engine does not expose one.')), }), 'Current vault engine.', ) const include = z .enum(['access', 'apy', 'capabilities', 'token.logoUri', 'tvl', 'zone', 'zones']) .check(z.describe('Optional vault fields to embed via `include`.')) const includeQuery = Schema.includeQuery( include, 'Comma-separated vault fields to include, such as `apy,tvl`.', ) // `apy` and `tvl` need indexer and FX access, so surfaces lacking either // select from this reduced set instead. const baseInclude = z .enum(['access', 'capabilities', 'token.logoUri', 'zone', 'zones']) .check(z.describe('Optional vault fields to embed via `include`.')) const baseIncludeQuery = Schema.includeQuery( baseInclude, 'Comma-separated vault fields to include, such as `access,zone`.', ) const zone = z .strictObject({ chainId: Schema.ChainId.check( z.describe('Private Zone chain id.'), z.meta({ examples: [421700001] }), ), inputTokens: z .array(Schema.Address) .check( z.describe('Tokens accepted by private deposits.'), z.meta({ examples: [['0x20c0000000000000000000006fd9a167923ba194']] }), ), name: z .string() .check(z.describe('Private Zone name.'), z.meta({ examples: ['zone-mainnet-internal'] })), outputTokens: z .array(Schema.Address) .check( z.describe('Tokens supported by private redemptions.'), z.meta({ examples: [['0x20c0000000000000000000006fd9a167923ba194']] }), ), }) .check(z.describe('Private Zone route configured for a verified vault.')) const zoneRoute = z .strictObject({ chainId: Schema.ChainId.check( z.describe('Private Zone chain id.'), z.meta({ examples: [421700001] }), ), deploymentBlock: z .number() .check( z.int(), z.nonnegative(), z.describe('Parent-chain block that deployed the Earn router.'), z.meta({ examples: [35816964] }), ), earnRouter: Schema.Address.check( z.describe('Parent-chain router for this Zone and Earn vault.'), z.meta({ examples: ['0x8117e0ba6239b9695f780deb010f72a2fa4bdfb6'] }), ), inputTokens: z .array(Schema.Address) .check( z.describe('Tokens accepted by private deposits.'), z.meta({ examples: [['0x20c0000000000000000000006fd9a167923ba194']] }), ), name: z .string() .check(z.describe('Private Zone name.'), z.meta({ examples: ['zone-mainnet-internal'] })), outputTokens: z .array(Schema.Address) .check( z.describe('Tokens returned by private redemptions.'), z.meta({ examples: [['0x20c0000000000000000000006fd9a167923ba194']] }), ), }) .check(z.describe('Verified Earn route through one private Zone.')) const value = z.strictObject({ amount: Schema.DecimalString.check( z.describe('Value in base units at `decimals` precision.'), z.meta({ examples: ['125288750000'] }), ), currency: z .string() .check(z.describe('Display currency of this value.'), z.meta({ examples: ['USD'] })), decimals: z .number() .check( z.int(), z.nonnegative(), z.describe('Decimal places used to convert `amount` into `formatted`.'), z.meta({ examples: [6] }), ), formatted: z .string() .check( z.regex(/^\d+(\.\d+)?$/), z.describe('Value rendered in whole units.'), z.meta({ examples: ['125288.75'] }), ), }) const vault = z.strictObject({ access: z .optional(EarnVaults.schema.Discovery.shape.access) .check(z.describe('Vault share-token access, when requested.')), apy: z .optional(z.nullable(EarnRates.schema.Apy)) .check( z.describe( 'Annualized share-price growth over the selected window, when requested via `include=apy` or by selecting `apy.window`. Null when the rate could not be measured: the vault predates the window, a boundary or historical quote is unavailable, the engine changed inside the window, or the growth is too extreme to annualize as a ratio.', ), ), assetToken: Tokens.schema.Token.check( z.describe('Asset accepted by the vault.'), z.meta({ examples: [ { address: '0x20c0000000000000000000000000000000000000', currency: 'USD', decimals: 6, id: '0x20c0000000000000000000000000000000000000', name: 'PathUSD', symbol: 'pathUSD', verified: true, }, ], }), ), capabilities: z .optional(EarnVaults.schema.Discovery.shape.capabilities) .check(z.describe('Vault capabilities, when requested.')), description: z .nullable(z.string()) .check( z.describe('Curated vault description, or null when the vault is not verified.'), z.meta({ examples: ['deelUSD deposited into the canonical Tempo Earn deployment.'] }), ), engine, id: Schema.Address.check( z.describe('Stable vault id, equal to the lowercase vault address.'), z.meta({ examples: ['0x4f94590b636f5878bce585e82379de81e1ec174f'] }), ), instantLiquidity: EarnVaults.schema.Discovery.shape.instantLiquidity.check( z.describe( 'Assets the venue would release right now, capped at the vault backing, in asset base units. Null when the engine interface cannot report a reliable number.', ), ), instantLiquidityValue: z .optional(z.nullable(value)) .check( z.describe( 'Instant liquidity valued in USD, when requested via `include=tvl`, or null when liquidity is unavailable or the asset has no priced display currency.', ), ), label: z .string() .check(z.describe('Vault display label.'), z.meta({ examples: ['deelUSD Earn'] })), sharePrice: z .nullable(value) .check( z.describe( 'Assets returned for one whole earn share, in the asset display currency, or null when the engine cannot quote an exit.', ), ), shareToken: Tokens.schema.Token.check( z.describe('Token representing vault shares.'), z.meta({ examples: [ { address: '0x20c000000000000000000000c3268d803b51d448', currency: 'USD', decimals: 6, id: '0x20c000000000000000000000c3268d803b51d448', name: 'Sentora pathUSD (Earn)', symbol: 'senpathUSDE', verified: false, }, ], }), ), slug: z .nullable(z.string()) .check( z.describe('Curated URL slug, or null when the vault is not verified.'), z.meta({ examples: ['deelusd-earn'] }), ), state: EarnVaults.schema.Discovery.shape.state, tvl: z .optional(z.nullable(value)) .check( z.describe( 'Total assets valued in USD, when requested via `include=tvl`, or null when the asset has no priced display currency.', ), ), vaultAddress: Schema.Address.check( z.describe('Earn vault contract address.'), z.meta({ examples: ['0x4f94590b636f5878bce585e82379de81e1ec174f'] }), ), verified: z .boolean() .check( z.describe('Whether this vault is in the curated earn registry.'), z.meta({ examples: [true] }), ), zone: z .optional(z.nullable(zone)) .check(z.describe('Curated private Zone route, when requested, or null when absent.')), zones: z.optional(z.array(zoneRoute)).check( z.describe('Curated private Zone routes, when requested.'), z.meta({ examples: [ [ { chainId: 421700001, deploymentBlock: 35816964, earnRouter: '0x8117e0ba6239b9695f780deb010f72a2fa4bdfb6', inputTokens: ['0x20c0000000000000000000006fd9a167923ba194'], name: 'zone-mainnet-internal', outputTokens: ['0x20c0000000000000000000006fd9a167923ba194'], }, ], ], }), ), }) /** One compatible Earn vault resolved from current chain state. */ export const Vault = Schema.describe(vault, 'A compatible earn vault resolved from chain state.') /** One registry-curated Earn vault. */ export const VerifiedVault = Schema.describe( z.extend(vault, { engine: z.extend(engine, { type: engineType }), slug: z .string() .check( z.describe('Stable curated vault slug.'), z.meta({ examples: ['btpathusd-earn-h8k2m4n6'] }), ), verified: z .literal(true) .check( z.describe('This vault is in the curated earn registry.'), z.meta({ examples: [true] }), ), }), 'A registry-curated earn vault resolved from chain state.', ) const position = z.strictObject({ account: Schema.Address.check( z.describe('Account whose earn position was read.'), z.meta({ examples: ['0xbe058e1c4df8a4366a387bf595b284246a93039e'] }), ), assetAllowance: Schema.DecimalString.check( z.describe('Assets the vault may spend from this account, in asset base units.'), z.meta({ examples: ['0'] }), ), assetBalance: Schema.DecimalString.check( z.describe('Assets held by this account at the observation block, in asset base units.'), z.meta({ examples: ['250000000'] }), ), assetToken: Schema.tokenAddress('0x20c000000000000000000000b9537d11c60e8b50').check( z.describe('TIP-20 asset token accepted by the vault.'), ), id: Schema.Address.check( z.describe('Stable resource ID for this vault position, equal to the account address.'), z.meta({ examples: ['0xbe058e1c4df8a4366a387bf595b284246a93039e'] }), ), shareAllowance: Schema.DecimalString.check( z.describe('Earn shares the vault may spend from this account, in share base units.'), z.meta({ examples: ['0'] }), ), shareBalance: Schema.DecimalString.check( z.describe('Earn shares held by this account at the observation block, in share base units.'), z.meta({ examples: ['99500000'] }), ), shareToken: Schema.tokenAddress('0x20c000000000000000000000300e14ab91a10769').check( z.describe('TIP-20 token representing earn shares.'), ), value: Schema.DecimalString.check( z.describe( 'Asset value of this account’s earn shares at the observation block, including fees, in asset base units.', ), z.meta({ examples: ['100000000'] }), ), }) const lifetimeCashFlows = z.union([ z.strictObject({ status: z .literal('complete') .check( z.describe('Cash flows are complete through the indexed boundary.'), z.meta({ examples: ['complete'] }), ), totalDeposited: Schema.DecimalString.check( z.describe('Assets deposited with this account as receiver over indexed vault history.'), ), totalWithdrawn: Schema.DecimalString.check( z.describe('Assets returned by completed withdrawals over indexed vault history.'), ), }), z.strictObject({ status: z .literal('incomplete_history') .check( z.describe('Cash flows are omitted because asset deposit history is incomplete.'), z.meta({ examples: ['incomplete_history'] }), ), }), ]) const addressPosition = z.strictObject({ assetAmount: value.check( z.describe('Current asset value of the held shares, including fees, in `assetToken` units.'), ), assetToken: Tokens.schema.Token.check(z.describe('Asset accepted by the vault.')), id: Schema.Address.check( z.describe('Stable resource ID for this position, equal to the vault address.'), z.meta({ examples: ['0x4f94590b636f5878bce585e82379de81e1ec174f'] }), ), lifetimeCashFlows: z .optional(lifetimeCashFlows) .check(z.describe('Lifetime deposits and completed withdrawals, when requested.')), shareAmount: value.check( z.describe('Earn share balance this account holds, in `shareToken` units.'), ), shareToken: Tokens.schema.Token.check(z.describe('Token representing vault shares.')), valuation: z .optional(z.nullable(Valuation.schema.Value)) .check( z.describe( 'The position’s nominal value when `valuation.currency` is requested, or `null` when ' + 'the asset token is unverified, its display currency has no rate, or rates were unavailable.', ), ), vaultAddress: Schema.Address.check( z.describe('Earn vault contract address holding this position.'), z.meta({ examples: ['0x4f94590b636f5878bce585e82379de81e1ec174f'] }), ), verified: z .boolean() .check( z.describe('Whether the vault is in the curated earn registry.'), z.meta({ examples: [true] }), ), }) const earningsBase = z.strictObject({ account: Schema.Address.check( z.describe('Account whose vault earnings were calculated.'), z.meta({ examples: ['0x68f94cfca22e5969c0553bbeab00a2acde88939b'] }), ), assetToken: Schema.TokenAddress.check( z.describe('TIP-20 asset token accepted by the vault.'), z.meta({ examples: ['0x20c000000000000000000000ff04042ee92fd449'] }), ), currentValue: Schema.DecimalString.check( z.describe( 'Asset value of the account’s active earn shares at the latest indexed block, in asset base units.', ), z.meta({ examples: ['600000'] }), ), id: Schema.Address.check( z.describe('Stable resource ID for these vault earnings, equal to the account address.'), z.meta({ examples: ['0x68f94cfca22e5969c0553bbeab00a2acde88939b'] }), ), }) const earningsPeriod = z .enum(['30d', 'active', 'lifetime']) .check( z.describe( 'Earnings period to calculate: trailing 30 days, shares still held, or lifetime history.', ), z.meta({ examples: ['lifetime'] }), ) const earnings = z.union([ z.extend(earningsBase, { lifetimeEarnings: z .string() .check( z.regex(/^-?\d+$/), z.describe( 'Current share value plus completed redemptions, exact withdrawals, and finalized async assets, minus asset deposits, at the latest indexed block.', ), z.meta({ examples: ['0'] }), ), totalDeposited: Schema.DecimalString.check( z.describe( 'Assets deposited with this account as receiver over complete indexed vault history.', ), z.meta({ examples: ['1000000'] }), ), totalWithdrawn: Schema.DecimalString.check( z.describe( 'Assets returned by completed redemptions and exact withdrawals initiated by this account over complete indexed vault history.', ), z.meta({ examples: ['1000000'] }), ), period: z .literal('lifetime') .check( z.describe('Returns lifetime net asset earnings.'), z.meta({ examples: ['lifetime'] }), ), status: z .literal('complete') .check( z.describe('Earnings include a complete asset-denominated cost basis.'), z.meta({ examples: ['complete'] }), ), }), z.extend(earningsBase, { activeEarnings: z .string() .check( z.regex(/^-?\d+$/), z.describe( 'Current share value minus the weighted-average asset cost basis of shares still held, at the latest indexed block.', ), z.meta({ examples: ['0'] }), ), period: z .literal('active') .check( z.describe('Returns earnings on shares still held.'), z.meta({ examples: ['active'] }), ), status: z .literal('complete') .check( z.describe('Earnings include a complete asset-denominated cost basis.'), z.meta({ examples: ['complete'] }), ), }), z.extend(earningsBase, { period: z .literal('30d') .check( z.describe('Returns cash-flow-adjusted earnings over the trailing 30 days.'), z.meta({ examples: ['30d'] }), ), status: z .literal('complete') .check( z.describe('Earnings include a complete asset-denominated cost basis.'), z.meta({ examples: ['complete'] }), ), windowEarnings: z .string() .check( z.regex(/^-?\d+$/), z.describe( 'Ending share value plus realized assets, minus deposits and opening share value, over the trailing 30 days.', ), z.meta({ examples: ['0'] }), ), }), z.extend(earningsBase, { period: earningsPeriod, status: z .literal('incomplete_cost_basis') .check( z.describe( 'Earnings are unavailable when indexed activity cannot reconstruct the asset cost basis for the requested period.', ), z.meta({ examples: ['incomplete_cost_basis'] }), ), }), z.extend(earningsBase, { period: z .literal('30d') .check( z.describe('Trailing period with an unresolved async redemption.'), z.meta({ examples: ['30d'] }), ), status: z .literal('pending_redemption') .check( z.describe('Earnings are unavailable while an async redemption remains open.'), z.meta({ examples: ['pending_redemption'] }), ), }), z.extend(earningsBase, { period: z .literal('lifetime') .check( z.describe('Lifetime period with an unresolved async redemption.'), z.meta({ examples: ['lifetime'] }), ), status: z .literal('pending_redemption') .check( z.describe('Earnings are unavailable while an async redemption remains open.'), z.meta({ examples: ['pending_redemption'] }), ), totalDeposited: Schema.DecimalString.check( z.describe('Assets deposited over complete indexed vault history.'), ), totalWithdrawn: Schema.DecimalString.check( z.describe('Assets withdrawn over complete indexed vault history.'), ), }), ]) const meta = z .object({ valuation: z .optional(Valuation.schema.Pricing) .check( z.describe( 'Rate provenance for valued fields. Present when `include=tvl` consulted conversion rates; absent when TVL was not requested, every value was identity-valued, or rates were unavailable.', ), ), }) .check(z.describe('Rate provenance for valued fields.')) const collectionFilters = z.strictObject({ asset: z .optional(Schema.Address) .check(z.describe('Only include vaults accepting this asset address.')), capability: Schema.includeQuery( capability, 'Comma-separated capabilities that every returned vault must support.', ), chainId: Schema.ChainIdQuery, cursor: Schema.Cursor, 'engine.type': z .optional(engineType) .check(z.describe('Only include vaults with this inferred engine type.')), include: includeQuery, limit: Schema.Limit, }) const collectionQuery = z .extend(collectionFilters, { 'apy.window': EarnRates.schema.Window }) .check(z.describe('Query parameters for listing earn vaults.')) const vaultSelector = z.strictObject({ chainId: Schema.ChainIdQuery, include: includeQuery, }) /** Schemas for the getEarnVaults operation. */ export namespace getEarnVaults { /** Query parameters for all compatible deployed Earn vaults. */ export const Query = collectionQuery /** Paginated list of compatible deployed Earn vaults. */ export const Response = z .strictObject({ data: z .array(Vault) .check(z.describe('Compatible deployed earn vaults in deployment order.')), meta: z.optional(meta), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of compatible deployed earn vaults.')) } /** Schemas for the getVerifiedEarnVaults operation. */ export namespace getVerifiedEarnVaults { /** Collection query without rates or valuation, for surfaces that cannot measure them. */ export const BaseQuery = Schema.describe( z.extend(collectionFilters, { include: baseIncludeQuery }), 'Query parameters for listing earn vaults.', ) /** Query parameters for registry-curated Earn vaults. */ export const Query = collectionQuery /** Paginated list of registry-curated Earn vaults. */ export const Response = z .strictObject({ data: z .array(VerifiedVault) .check(z.describe('Registry-curated earn vaults ordered by vault address.')), meta: z.optional(meta), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of registry-curated earn vaults.')) } /** Schemas for the getEarnAddressPositions operation. */ export namespace getEarnAddressPositions { /** Path parameters identifying the account. */ export const Params = z .strictObject({ address: Schema.Address.check(z.describe('Account whose earn positions to list.')), }) .check(z.describe('Path parameters identifying an account with earn positions.')) /** Query parameters for listing an account's earn positions. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, cursor: Schema.Cursor, include: Schema.includeQuery( z.enum(['earnings']), 'Comma-separated position fields to include, such as `earnings`.', ), limit: Schema.Limit, 'valuation.currency': Schema.Denomination.check( z.describe( 'When present, include each position’s nominal value in this denomination. ' + 'Case-insensitive and must be priced by the configured FX oracle.', ), ), verified: z .optional(Schema.booleanQuery()) .check( z.describe( 'When `true`, include only positions in registry-curated (verified) earn vaults.', ), z.meta({ examples: [true] }), ), }) .check(z.describe('Query parameters for listing an account’s earn positions.')) /** Page-level valuation rate provenance. */ export const Meta = z .object({ valuation: z .optional(Valuation.schema.Pricing) .check( z.describe( 'Rate provenance for row valuations. Present when conversion rates were ' + 'consulted; absent when every value was identity-valued or rates were unavailable.', ), ), }) .check(z.describe('Page-level valuation rate provenance.')) /** Paginated list of the account's earn vault positions. */ export const Response = z .strictObject({ data: z .array(addressPosition) .check(z.describe('Vaults where the account holds earn shares.')), meta: z.optional(Meta), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of the account’s earn vault positions.')) } /** Schemas for the getEarnVault operation. */ export namespace getEarnVault { /** Path parameters identifying one earn vault. */ export const Params = z .strictObject({ vaultId: Schema.Address.check(z.describe('Earn vault contract address.')), }) .check(z.describe('Path parameters identifying one earn vault.')) /** Detail query without rates or valuation, for surfaces that cannot measure them. */ export const BaseQuery = Schema.describe( z.extend(vaultSelector, { include: baseIncludeQuery }), 'Query parameters selecting one earn vault.', ) /** Query parameters selecting the chain containing the vault. */ export const Query = z .extend(vaultSelector, { 'apy.window': EarnRates.schema.Window }) .check(z.describe('Query parameters selecting one earn vault.')) /** One compatible Earn vault resolved directly from current chain state. */ export const Response = Schema.describe( z.extend(vault, { meta: z.optional(meta) }), 'A compatible earn vault resolved from chain state, with rate provenance for its valued fields.', ) } /** Schemas for the getEarnVaultPosition operation. */ export namespace getEarnVaultPosition { const asOf = z.iso.datetime({ offset: true }) /** Path parameters identifying the Earn vault and account. */ export const Params = z .strictObject({ address: Schema.Address.check(z.describe('Account whose earn position to read.')), vaultId: Schema.address('0x4f94590b636f5878bce585e82379de81e1ec174f').check( z.describe('Earn vault contract address.'), ), }) .check(z.describe('Path parameters identifying an earn vault position.')) /** Query parameters selecting the chain containing the vault. */ export const Query = z .strictObject({ asOf: z .optional(asOf) .check( z.describe( 'ISO 8601 timestamp at which to read the position. Resolves the latest indexed block at or before this time.', ), z.meta({ examples: ['2026-07-21T09:00:00.000Z'] }), ), chainId: Schema.ChainIdQuery, }) .check(z.describe('Query parameters selecting an earn vault position.')) const Block = z .strictObject({ number: z .number() .check( z.int(), z.nonnegative(), z.describe('Indexed observation block.'), z.meta({ examples: [31039238] }), ), timestamp: z.iso .datetime() .check( z.describe('Timestamp of the indexed observation block.'), z.meta({ examples: ['2026-07-21T09:00:00.000Z'] }), ), }) .check(z.describe('Indexed block used for a historical Earn position observation.')) /** Current or historical asset and Earn-share state for one account in one vault. */ export const Response = Schema.describe( z.extend(position, { asOf: z .optional(asOf) .check( z.describe('Requested historical observation time, when supplied.'), z.meta({ examples: ['2026-07-21T09:00:00.000Z'] }), ), block: z .optional(Block) .check( z.describe( 'Indexed block used for a historical Earn position observation, when requested.', ), ), }), 'Current or historical asset and earn-share balances, allowances, and asset value for an account in one earn vault.', ) } /** Schemas for the getEarnVaultEarnings operation. */ export namespace getEarnVaultEarnings { /** Path parameters identifying the Earn vault and account. */ export const Params = z .strictObject({ address: Schema.Address.check(z.describe('Account whose vault earnings to read.')), vaultId: Schema.address('0xf4ae63687d6753a78e7f551d2eda1d0d31a5ea3a').check( z.describe('Earn vault contract address.'), ), }) .check(z.describe('Path parameters identifying an earn vault account.')) /** Query parameters selecting the chain containing the vault. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, period: z._default(earningsPeriod, 'lifetime'), }) .check(z.describe('Query parameters selecting an earn vault account.')) /** Earnings for one account and period in one Earn vault. */ export const Response = Schema.describe( earnings, 'Indexed asset value and cost-basis-aware earnings for an account in one earn vault.', ) } } /** Creates the public Earn-vault handlers. */ export function earn(options: earn.Options = {}) { const oracle = options.fx?.oracle ?? FxOracle.ecb() return new Hono() .get( '/v1/earn/vaults', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('query', schema.getEarnVaults.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Lists compatible earn vaults discovered from deployment events and resolved from current chain state.', operationId: 'getEarnVaults', responses: OpenApi.responses({ errors: { 502: 'Could not read earn deployment or vault data.' }, success: { description: 'A page of compatible deployed earn vaults.', schema: schema.getEarnVaults.Response, }, }), summary: 'List vaults', tags: ['Earn'], }), // Live state and rate enrichment are embedded, so every vault read caches // as current-state rather than slow-changing metadata. Cache.response({ cacheControl: Cache.policies.state, key: (c) => vaultCacheKey(c, schema.getEarnVaults.Query), name: 'tempo-api:earn-vaults:v4', }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid query parameters', }) const query = c.req.valid('query') try { const chainId = query.chainId ?? c.get('chainId') const data = await listVaults(c, { ...query, chainId, oracle }) return c.json(Response.validated(schema.getEarnVaults.Response, data), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/earn/vaults/verified', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('query', schema.getVerifiedEarnVaults.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Lists registry-curated earn vaults after resolving their current onchain configuration.', operationId: 'getVerifiedEarnVaults', responses: OpenApi.responses({ errors: { 502: 'Could not read curated earn registry or vault data.' }, success: { description: 'A page of registry-curated earn vaults.', schema: schema.getVerifiedEarnVaults.Response, }, }), summary: 'List verified vaults', tags: ['Earn'], }), Cache.response({ cacheControl: Cache.policies.state, key: (c) => vaultCacheKey(c, schema.getVerifiedEarnVaults.Query), name: 'tempo-api:earn-vaults:v4', }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid query parameters', }) const query = c.req.valid('query') try { const chainId = query.chainId ?? c.get('chainId') const data = await listVerifiedVaults(c, { ...query, chainId, oracle }) return c.json(Response.validated(schema.getVerifiedEarnVaults.Response, data), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/earn/addresses/:address/positions', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getEarnAddressPositions.Params, { code: 'positions_invalid', message: 'Invalid address positions', }), OpenApi.validate('query', schema.getEarnAddressPositions.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Lists every earn vault where an account currently holds shares, with current balances and optional lifetime cash flows.', operationId: 'getEarnAddressPositions', responses: OpenApi.responses({ errors: { 400: { codes: [ 'chain_id_invalid', 'chain_id_unsupported', 'positions_invalid', 'query_invalid', ], }, 502: 'Could not read earn deployment or position data.', }, success: { description: 'A page of the account’s earn vault positions.', schema: schema.getEarnAddressPositions.Response, }, }), summary: 'List account positions', tags: ['Earn'], }), // `noStore` matches the address balances listing: account holdings must // read fresh immediately after a deposit or redemption. Cache.response({ cacheControl: Cache.policies.noStore, key: (c) => Cache.urlKey(c, schema.getEarnAddressPositions.Query), name: 'tempo-api:earn-address-positions:v2', }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return Response.error(c, { code: 'chain_id_invalid', message: 'Invalid chain id', status: 400, }) if (OpenApi.narrowValidation) return Response.error(c, { code: 'chain_id_unsupported', message: 'Unsupported chain id', status: 400, }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'positions_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid query parameters', }) const { address } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? c.get('chainId') try { const data = await listPositions(c, { ...query, address, chainId, oracle }) return c.json(Response.validated(schema.getEarnAddressPositions.Response, data), 200) } catch (cause) { if (cause instanceof Valuation.UnsupportedDenominationError) return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 }) return Response.upstream(c, cause) } }, ) .get( '/v1/earn/vaults/:vaultId/positions/:address', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getEarnVaultPosition.Params, { code: 'position_invalid', message: 'Invalid vault position', }), OpenApi.validate('query', schema.getEarnVaultPosition.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Gets an account’s asset and earn-share balances, allowances, and asset value for one earn vault, optionally at an ISO 8601 timestamp.', operationId: 'getEarnVaultPosition', responses: OpenApi.responses({ errors: { 400: { codes: [ 'chain_id_invalid', 'chain_id_unsupported', 'position_invalid', 'query_invalid', ], }, 404: { codes: ['earn_vault_not_found'], description: 'No compatible earn vault was found at the supplied vault address.', }, 502: 'Could not read the earn vault position.', }, success: { description: 'Current or historical earn position for one account in one vault.', schema: schema.getEarnVaultPosition.Response, }, }), summary: 'Get account position', tags: ['Earn'], }), // Account holdings must read fresh immediately after a deposit or redemption. Cache.response({ cacheControl: Cache.policies.noStore, key: (c) => Cache.urlKey(c, schema.getEarnVaultPosition.Query), name: 'tempo-api:earn-vault-positions:v2', }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return Response.error(c, { code: 'chain_id_invalid', message: 'Invalid chain id', status: 400, }) if (OpenApi.narrowValidation) return Response.error(c, { code: 'chain_id_unsupported', message: 'Unsupported chain id', status: 400, }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'position_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid query parameters', }) const { address, vaultId } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? c.get('chainId') const client = c.get('getClient')(chainId) try { const block = query.asOf === undefined ? undefined : await getIndexedBlockAtOrBefore(c.get('getTidx')(chainId), query.asOf) try { const position = await client.earn.getPosition({ account: address, ...(block ? { blockNumber: BigInt(block.number) } : {}), vault: vaultId, }) return c.json( Response.validated(schema.getEarnVaultPosition.Response, { account: address, assetAllowance: position.assetAllowance.toString(), assetBalance: position.assetBalance.toString(), assetToken: position.assetToken, asOf: query.asOf, block, id: address, shareAllowance: position.shareAllowance.toString(), shareBalance: position.shareBalance.toString(), shareToken: position.shareToken, value: position.value.toString(), }), 200, ) } catch (cause) { if (block) { try { // These immutable bindings establish historical vault identity // without repeating fallible token or redemption subreads. const [asset, shareToken] = await Promise.all([ client.readContract({ abi: Abis.earnVault, address: vaultId, blockNumber: BigInt(block.number), functionName: 'asset', }), client.readContract({ abi: Abis.earnVault, address: vaultId, blockNumber: BigInt(block.number), functionName: 'earnShare', }), ]) if (asset === zeroAddress || shareToken === zeroAddress) return Response.error(c, { code: 'earn_vault_not_found', message: 'Earn vault not found', status: 404, }) } catch (identityCause) { if (isIncompatible(identityCause)) return Response.error(c, { code: 'earn_vault_not_found', message: 'Earn vault not found', status: 404, }) return Response.upstream(c, identityCause) } return Response.upstream(c, cause) } try { await resolveVaultDiscovery(c, { chainId, include: [], vaultAddress: vaultId }) } catch (vaultCause) { if (isIncompatible(vaultCause)) return Response.error(c, { code: 'earn_vault_not_found', message: 'Earn vault not found', status: 404, }) } return Response.upstream(c, cause) } } catch (cause) { if (cause instanceof IndexedBlockNotFoundError || cause instanceof IndexedCoverageError) return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 }) return Response.upstream(c, cause) } }, ) .get( '/v1/earn/vaults/:vaultId/earnings/:address', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getEarnVaultEarnings.Params, { code: 'earnings_invalid', message: 'Invalid vault earnings', }), OpenApi.validate('query', schema.getEarnVaultEarnings.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Gets an account’s indexed asset value and earnings for lifetime history, the trailing 30 days, or shares still held.', operationId: 'getEarnVaultEarnings', responses: OpenApi.responses({ errors: { 400: { codes: [ 'chain_id_invalid', 'chain_id_unsupported', 'earnings_invalid', 'query_invalid', ], }, 404: { codes: ['earn_vault_not_found'], description: 'No compatible earn vault was found at this address.', }, 502: 'Could not read the earn vault earnings.', }, success: { description: 'Cost-basis-aware earnings for one account in one vault.', schema: schema.getEarnVaultEarnings.Response, }, }), summary: 'Get vault earnings', tags: ['Earn'], }), Cache.response({ cacheControl: Cache.policies.state, key: (c) => Cache.urlKey(c, schema.getEarnVaultEarnings.Query), name: 'tempo-api:earn-vault-earnings:v4', }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return Response.error(c, { code: 'chain_id_invalid', message: 'Invalid chain id', status: 400, }) if (OpenApi.narrowValidation) return Response.error(c, { code: 'chain_id_unsupported', message: 'Unsupported chain id', status: 400, }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'earnings_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid query parameters', }) const { address, vaultId } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? c.get('chainId') try { const data = await getVaultEarnings(c, { account: address, chainId, period: query.period, vault: vaultId, }) return c.json(Response.validated(schema.getEarnVaultEarnings.Response, data), 200) } catch (cause) { try { await resolveVaultDiscovery(c, { chainId, include: [], vaultAddress: vaultId }) } catch (vaultCause) { if (isIncompatible(vaultCause)) return Response.error(c, { code: 'earn_vault_not_found', message: 'Earn vault not found', status: 404, }) } return Response.upstream(c, cause) } }, ) .get( '/v1/earn/vaults/:vaultId', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getEarnVault.Params, { code: 'vault_id_invalid', message: 'Invalid vault id', }), OpenApi.validate('query', schema.getEarnVault.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Resolves any compatible earn vault directly from current chain state and enriches it when curated.', operationId: 'getEarnVault', responses: OpenApi.responses({ errors: { 404: { codes: ['earn_vault_not_found'], description: 'No compatible earn vault was found at this address.', }, 502: 'Could not read earn vault data.', }, success: { description: 'One compatible earn vault.', schema: schema.getEarnVault.Response, }, }), summary: 'Get vault', tags: ['Earn'], }), Cache.response({ cacheControl: Cache.policies.state, key: (c) => vaultCacheKey(c, schema.getEarnVault.Query), name: 'tempo-api:earn-vaults:v4', }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'vault_id_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid request parameters', }) const { vaultId } = c.req.valid('param') const query = c.req.valid('query') try { const chainId = query.chainId ?? c.get('chainId') const data = await getVault(c, { 'apy.window': query['apy.window'], chainId, include: query.include, oracle, vaultAddress: vaultId, }) return c.json(Response.validated(schema.getEarnVault.Response, data), 200) } catch (cause) { if (cause instanceof NotFoundError) return Response.error(c, { code: 'earn_vault_not_found', message: 'Earn vault not found', status: 404, }) return Response.upstream(c, cause) } }, ) } export declare namespace earn { /** Options for the public Earn-vault handlers. */ type Options = { /** FX configuration backing TVL valuation. */ fx?: Valuation.addresses.Fx | undefined } } /** Reads one account's indexed value and returns earnings when the history is complete. */ export async function getVaultEarnings( c: Context, options: getVaultEarnings.Options, ) { const client = c.get('getClient')(options.chainId) const tidx = c.get('getTidx')(options.chainId) const block = await getLatestIndexedBlock(tidx) const blockNumber = block.number if (options.period === '30d') return getWindowVaultEarnings(c, { account: options.account, blockNumber, chainId: options.chainId, period: options.period, timestamp: block.timestamp, vault: options.vault, }) const position = client.earn.getPosition({ account: options.account, blockNumber: BigInt(blockNumber), vault: options.vault, }) if (options.period === 'active') { const current = await position const base = { account: options.account, assetToken: current.assetToken, currentValue: current.value.toString(), id: options.account, period: options.period, } const activeCostBasis = await getActiveCostBasis(tidx, { account: options.account, blockNumber, shareBalance: current.shareBalance, shareToken: current.shareToken, vault: options.vault, }) if (activeCostBasis === undefined) return { ...base, status: 'incomplete_cost_basis' as const } return { ...base, activeEarnings: (current.value - activeCostBasis).toString(), status: 'complete' as const, } } const [history, current] = await Promise.all([ getVaultEarningsHistory(tidx, { account: options.account, blockNumber, startBlockNumber: beforeFirstBlockNumber, vault: options.vault, }), position, ]) const externalShareTransfer = await hasExternalShareTransfer(tidx, { account: options.account, blockNumber, shareToken: current.shareToken, startBlockNumber: beforeFirstBlockNumber, vault: options.vault, }) const base = { account: options.account, assetToken: current.assetToken, currentValue: current.value.toString(), id: options.account, period: options.period, } if (history.incompleteCostBasis || externalShareTransfer) return { ...base, status: 'incomplete_cost_basis' as const } const totalWithdrawn = history.redeemed + history.withdrewExact + history.finalized const cashFlows = { totalDeposited: history.deposited.toString(), totalWithdrawn: totalWithdrawn.toString(), } if (history.pendingRedemption) return { ...base, ...cashFlows, status: 'pending_redemption' as const } const lifetimeEarnings = current.value + totalWithdrawn - history.deposited return { ...base, ...cashFlows, lifetimeEarnings: lifetimeEarnings.toString(), status: 'complete' as const, } } /** Reads cash-flow-adjusted earnings over a trailing indexed window. */ async function getWindowVaultEarnings( c: Context, options: getWindowVaultEarnings.Options, ) { const client = c.get('getClient')(options.chainId) const tidx = c.get('getTidx')(options.chainId) const [current, startBlockNumber] = await Promise.all([ client.earn.getPosition({ account: options.account, blockNumber: BigInt(options.blockNumber), vault: options.vault, }), getWindowStartBlockNumber(tidx, { days: 30, timestamp: options.timestamp }), ]) const [externalShareTransfer, history, opening] = await Promise.all([ hasExternalShareTransfer(tidx, { account: options.account, blockNumber: options.blockNumber, shareToken: current.shareToken, startBlockNumber, vault: options.vault, }), getVaultEarningsHistory(tidx, { account: options.account, blockNumber: options.blockNumber, startBlockNumber, vault: options.vault, }), getWindowShareState(tidx, { account: options.account, blockNumber: options.blockNumber, shareToken: current.shareToken, startBlockNumber, vault: options.vault, }), ]) const base = { account: options.account, assetToken: current.assetToken, currentValue: current.value.toString(), id: options.account, period: options.period, } if ( opening === undefined || externalShareTransfer || history.incompleteCostBasis || opening.crossBoundaryRedemption || opening.endShares !== current.shareBalance ) return { ...base, status: 'incomplete_cost_basis' as const } if (history.pendingRedemption) return { ...base, status: 'pending_redemption' as const } const openingValue = opening.startShares === 0n ? 0n : await client.earn.getRedeemQuote({ blockNumber: BigInt(startBlockNumber), shareAmount: opening.startShares, vault: options.vault, }) const windowEarnings = calculateWindowEarnings({ deposited: history.deposited, endingValue: current.value, finalized: history.finalized, openingValue, redeemed: history.redeemed, withdrewExact: history.withdrewExact, }) return { ...base, status: 'complete' as const, windowEarnings: windowEarnings.toString() } } declare namespace getWindowVaultEarnings { /** Inputs identifying one account, vault, and indexed window end. */ type Options = { /** Account whose earnings are calculated. */ account: z.output /** Latest indexed block number. */ blockNumber: number /** Tempo chain containing the vault. */ chainId: z.output /** Trailing earnings period. */ period: '30d' /** Latest indexed block timestamp. */ timestamp: string /** Earn vault contract address. */ vault: z.output } } /** Calculates cash-flow-adjusted earnings between two position snapshots. */ export function calculateWindowEarnings(options: calculateWindowEarnings.Options) { return ( options.endingValue + options.redeemed + options.withdrewExact + options.finalized - options.deposited - options.openingValue ) } export declare namespace calculateWindowEarnings { /** Asset-denominated values spanning one earnings window. */ type Options = { /** Assets deposited during the window. */ deposited: bigint /** Asset value of shares held at the ending boundary. */ endingValue: bigint /** Assets received from async redemptions finalized during the window. */ finalized: bigint /** Asset value of shares held at the opening boundary. */ openingValue: bigint /** Assets received from redemptions during the window. */ redeemed: bigint /** Assets received from exact withdrawals during the window. */ withdrewExact: bigint } } export declare namespace getVaultEarnings { /** Inputs identifying the account and vault to calculate. */ type Options = { account: z.output chainId: z.output period: z.output['period'] vault: z.output } } /** Reads the latest block whose indexed logs back the earnings calculation. */ async function getLatestIndexedBlock(tidx: Tidx.Client) { const result = await tidx.fetch({ engine: 'clickhouse', query: 'SELECT num, timestamp FROM blocks ORDER BY num DESC LIMIT 1' as string, }) const number = Value.toNumber(result.rows[0]?.['num']) const timestamp = Value.toIsoDateTime(result.rows[0]?.['timestamp']) if (number === undefined || timestamp === undefined) throw new Error('TIDX returned no indexed block.') return { number, timestamp } } /** Resolves the latest indexed block at or before a historical position timestamp. */ async function getIndexedBlockAtOrBefore(tidx: Tidx.Client, asOf: string) { const timestamp = new Date(asOf).toISOString().replace('T', ' ').replace(/Z$/, '') const result = await tidx.fetch({ engine: 'clickhouse', // SAFETY: Zod restricts `asOf` to ISO 8601, so the formatted timestamp cannot alter SQL. query: ` SELECT 'boundary' AS kind, num, timestamp FROM ( SELECT num, timestamp FROM blocks WHERE timestamp <= '${timestamp}' ORDER BY timestamp DESC, num DESC LIMIT 1 ) UNION ALL SELECT 'head' AS kind, num, timestamp FROM ( SELECT num, timestamp FROM blocks ORDER BY num DESC LIMIT 1 ) ` as string, }) return parseIndexedBlockAtOrBefore({ asOf, rows: result.rows }) } /** Decodes the indexed head and historical position boundary from TIDX rows. */ export function parseIndexedBlockAtOrBefore(options: parseIndexedBlockAtOrBefore.Options) { const head = options.rows.find((row) => row['kind'] === 'head') if (!head) throw new Error('TIDX returned no indexed head for a historical Earn position.') const headNumber = Value.toNumber(head['num']) const headAt = Value.toIsoDateTime(head['timestamp']) if (headNumber === undefined || headAt === undefined) throw new Error('TIDX returned a malformed indexed head for a historical Earn position.') const boundary = options.rows.find((row) => row['kind'] === 'boundary') if (!boundary) throw new IndexedBlockNotFoundError(options.asOf) const number = Value.toNumber(boundary['num']) const indexedAt = Value.toIsoDateTime(boundary['timestamp']) if (number === undefined || indexedAt === undefined) throw new Error('TIDX returned a malformed historical Earn position boundary.') if (Date.parse(headAt) < Date.parse(options.asOf)) throw new IndexedCoverageError(options.asOf) return { number, timestamp: indexedAt } } export declare namespace parseIndexedBlockAtOrBefore { /** Inputs returned by the historical boundary query. */ type Options = { /** Requested historical observation timestamp. */ asOf: string /** Indexed head and optional boundary rows. */ rows: readonly Record[] } } /** Resolves the last indexed block at or before a trailing-day boundary. */ async function getWindowStartBlockNumber( tidx: Tidx.Client, options: getWindowStartBlockNumber.Options, ) { const cutoff = new Date(Date.parse(options.timestamp) - Ttl.days(options.days)).toISOString() const result = await tidx.fetch({ query: `SELECT num FROM blocks WHERE timestamp <= '${cutoff}' ORDER BY num DESC LIMIT 1` as string, }) const row = result.rows[0] if (!row) return beforeFirstBlockNumber const blockNumber = Value.toNumber(row['num']) if (blockNumber === undefined) throw new Error('TIDX returned no Earn earnings boundary.') return blockNumber } declare namespace getWindowStartBlockNumber { /** Inputs defining a trailing boundary from an indexed timestamp. */ type Options = { /** Number of trailing days. */ days: number /** Timestamp anchoring the end of the window. */ timestamp: string } } /** Reads the indexed cash flows and exceptional states behind one earnings calculation. */ async function getVaultEarningsHistory( tidx: Tidx.Client, options: getVaultEarningsHistory.Options, ) { const result = await tidx.fetch({ engine: 'clickhouse', query: ` SELECT ( SELECT toString(sum(toUInt256(assets))) FROM Deposited WHERE address = '${options.vault}' AND receiver = '${options.account}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} ) AS deposited_assets, ( SELECT toString(sum(toUInt256(finalized.assets))) FROM RedeemFinalized AS finalized INNER JOIN RedeemRequested AS requested ON requested.address = finalized.address AND requested."requestId" = finalized."requestId" WHERE finalized.address = '${options.vault}' AND requested.requester = '${options.account}' AND finalized.block_num > ${options.startBlockNumber} AND finalized.block_num <= ${options.blockNumber} AND requested.block_num <= ${options.blockNumber} ) AS finalized_assets, ( SELECT count() FROM RedeemRequested AS requested LEFT ANTI JOIN RedeemFinalized AS finalized ON finalized.address = requested.address AND finalized."requestId" = requested."requestId" AND finalized.block_num <= ${options.blockNumber} LEFT ANTI JOIN RedeemCancelled AS cancelled ON cancelled.address = requested.address AND cancelled."requestId" = requested."requestId" AND cancelled.block_num <= ${options.blockNumber} WHERE requested.address = '${options.vault}' AND requested.requester = '${options.account}' AND requested.block_num <= ${options.blockNumber} ) AS pending_redemptions, ( SELECT toString(sum(toUInt256(assets))) FROM Redeemed WHERE address = '${options.vault}' AND caller = '${options.account}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} ) AS redeemed_assets, ( SELECT count() FROM RedeemCancelled AS cancelled INNER JOIN RedeemRequested AS requested ON requested.address = cancelled.address AND requested."requestId" = cancelled."requestId" WHERE cancelled.address = '${options.vault}' AND cancelled.block_num > ${options.startBlockNumber} AND cancelled.block_num <= ${options.blockNumber} AND requested.block_num <= ${options.blockNumber} AND requested.requester != cancelled.receiver AND ( requested.requester = '${options.account}' OR cancelled.receiver = '${options.account}' ) ) AS transferred_cancellations, ( SELECT count() FROM VenueSharesDeposited WHERE address = '${options.vault}' AND receiver = '${options.account}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} ) AS venue_deposits, ( SELECT toString(sum(toUInt256(assets))) FROM WithdrewExact WHERE address = '${options.vault}' AND caller = '${options.account}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} ) AS withdrew_exact ` as string, signatures: [ depositedSignature, redeemCancelledSignature, redeemFinalizedSignature, redeemRequestedSignature, redeemedSignature, venueSharesDepositedSignature, withdrewExactSignature, ], }) const row = result.rows[0] const deposited = Value.toIntegerString(row?.['deposited_assets']) const finalized = Value.toIntegerString(row?.['finalized_assets']) const pendingRedemptions = Value.toNumber(row?.['pending_redemptions']) const redeemed = Value.toIntegerString(row?.['redeemed_assets']) const transferredCancellations = Value.toNumber(row?.['transferred_cancellations']) const venueDeposits = Value.toNumber(row?.['venue_deposits']) const withdrewExact = Value.toIntegerString(row?.['withdrew_exact']) if ( deposited === undefined || finalized === undefined || pendingRedemptions === undefined || redeemed === undefined || transferredCancellations === undefined || venueDeposits === undefined || withdrewExact === undefined ) throw new Error('TIDX returned invalid Earn earnings history.') return { deposited: BigInt(deposited), finalized: BigInt(finalized), incompleteCashFlows: venueDeposits > 0, incompleteCostBasis: transferredCancellations > 0 || venueDeposits > 0, pendingRedemption: pendingRedemptions > 0, redeemed: BigInt(redeemed), withdrewExact: BigInt(withdrewExact), } } declare namespace getVaultEarningsHistory { type Options = { account: z.output blockNumber: number startBlockNumber: number vault: z.output } } /** Reads share balances and cross-boundary redemptions for a trailing window. */ async function getWindowShareState(tidx: Tidx.Client, options: getWindowShareState.Options) { const result = await tidx.fetch({ engine: 'clickhouse', query: ` SELECT ( SELECT toString(sum(toUInt256(amount))) FROM token_transfers WHERE token = '${options.shareToken.toLowerCase()}' AND "to" = '${options.account}' AND block_num <= ${options.startBlockNumber} ) AS received_start, ( SELECT toString(sum(toUInt256(amount))) FROM token_transfers WHERE token = '${options.shareToken.toLowerCase()}' AND "from" = '${options.account}' AND block_num <= ${options.startBlockNumber} ) AS sent_start, ( SELECT toString(sum(toUInt256(amount))) FROM token_transfers WHERE token = '${options.shareToken.toLowerCase()}' AND "to" = '${options.account}' AND block_num <= ${options.blockNumber} ) AS received_end, ( SELECT toString(sum(toUInt256(amount))) FROM token_transfers WHERE token = '${options.shareToken.toLowerCase()}' AND "from" = '${options.account}' AND block_num <= ${options.blockNumber} ) AS sent_end, ( SELECT count() FROM ( SELECT finalized."requestId" FROM RedeemFinalized AS finalized INNER JOIN RedeemRequested AS requested ON requested.address = finalized.address AND requested."requestId" = finalized."requestId" WHERE finalized.address = '${options.vault}' AND requested.requester = '${options.account}' AND requested.block_num <= ${options.startBlockNumber} AND finalized.block_num > ${options.startBlockNumber} AND finalized.block_num <= ${options.blockNumber} UNION ALL SELECT cancelled."requestId" FROM RedeemCancelled AS cancelled INNER JOIN RedeemRequested AS requested ON requested.address = cancelled.address AND requested."requestId" = cancelled."requestId" WHERE cancelled.address = '${options.vault}' AND requested.requester = '${options.account}' AND requested.block_num <= ${options.startBlockNumber} AND cancelled.block_num > ${options.startBlockNumber} AND cancelled.block_num <= ${options.blockNumber} ) AS cross_boundary ) AS cross_boundary_redemptions ` as string, signatures: [redeemCancelledSignature, redeemFinalizedSignature, redeemRequestedSignature], }) const row = result.rows[0] const crossBoundaryRedemptions = Value.toNumber(row?.['cross_boundary_redemptions']) const receivedEnd = Value.toIntegerString(row?.['received_end']) const receivedStart = Value.toIntegerString(row?.['received_start']) const sentEnd = Value.toIntegerString(row?.['sent_end']) const sentStart = Value.toIntegerString(row?.['sent_start']) if ( crossBoundaryRedemptions === undefined || receivedEnd === undefined || receivedStart === undefined || sentEnd === undefined || sentStart === undefined ) return undefined const receivedEndValue = BigInt(receivedEnd) const receivedStartValue = BigInt(receivedStart) const sentEndValue = BigInt(sentEnd) const sentStartValue = BigInt(sentStart) if (sentEndValue > receivedEndValue || sentStartValue > receivedStartValue) return undefined return { crossBoundaryRedemption: crossBoundaryRedemptions > 0, endShares: receivedEndValue - sentEndValue, startShares: receivedStartValue - sentStartValue, } } declare namespace getWindowShareState { /** Inputs identifying one account's share history window. */ type Options = { /** Account whose share history is read. */ account: z.output /** Ending indexed block number. */ blockNumber: number /** Earn share token contract address. */ shareToken: z.output /** Opening indexed block number. */ startBlockNumber: number /** Earn vault contract address. */ vault: z.output } } /** Returns whether an account sent or received Earn shares outside known vault flows. */ async function hasExternalShareTransfer( tidx: Tidx.Client, options: hasExternalShareTransfer.Options, ) { // Vault exits pull shares into the vault before burning. Reconcile counts by transaction and amount so unmatched direct sends remain external. const result = await tidx.fetch({ engine: 'clickhouse', query: ` SELECT ( SELECT count() FROM token_transfers WHERE token = '${options.shareToken.toLowerCase()}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} AND ( ("from" = '${options.account}' AND "to" != '${options.vault}') OR ( "to" = '${options.account}' AND "from" != '${zeroAddress}' AND "from" != '${options.vault}' ) ) ) + ( SELECT count() FROM ( SELECT tx_hash, toUInt256(amount) AS amount, count() AS transfer_count FROM token_transfers WHERE token = '${options.shareToken.toLowerCase()}' AND "from" = '${options.account}' AND "to" = '${options.vault}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} GROUP BY tx_hash, amount ) AS candidate LEFT JOIN ( SELECT tx_hash, amount, count() AS exit_count FROM ( SELECT tx_hash, toUInt256(earnShares) AS amount FROM Redeemed WHERE address = '${options.vault}' AND caller = '${options.account}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} UNION ALL SELECT tx_hash, toUInt256(earnShares) AS amount FROM RedeemRequested WHERE address = '${options.vault}' AND requester = '${options.account}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} UNION ALL SELECT tx_hash, toUInt256(earnSharesBurned) AS amount FROM WithdrewExact WHERE address = '${options.vault}' AND caller = '${options.account}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} ) AS protocol_exit GROUP BY tx_hash, amount ) AS matched ON matched.tx_hash = candidate.tx_hash AND matched.amount = candidate.amount WHERE candidate.transfer_count > coalesce(matched.exit_count, 0) ) + ( SELECT count() FROM ( SELECT tx_hash, toUInt256(amount) AS amount, count() AS transfer_count FROM token_transfers WHERE token = '${options.shareToken.toLowerCase()}' AND "from" = '${options.vault}' AND "to" = '${options.account}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} GROUP BY tx_hash, amount ) AS candidate LEFT JOIN ( SELECT tx_hash, toUInt256(earnShares) AS amount, count() AS cancel_count FROM RedeemCancelled WHERE address = '${options.vault}' AND receiver = '${options.account}' AND block_num > ${options.startBlockNumber} AND block_num <= ${options.blockNumber} GROUP BY tx_hash, amount ) AS matched ON matched.tx_hash = candidate.tx_hash AND matched.amount = candidate.amount WHERE candidate.transfer_count > coalesce(matched.cancel_count, 0) ) AS count ` as string, signatures: [ redeemCancelledSignature, redeemedSignature, redeemRequestedSignature, withdrewExactSignature, ], }) const count = Value.toNumber(result.rows[0]?.['count']) if (count === undefined) throw new Error('TIDX returned an invalid Earn-share transfer count.') return count > 0 } declare namespace hasExternalShareTransfer { type Options = { account: z.output blockNumber: number shareToken: z.output startBlockNumber: number vault: z.output } } /** Calculates the asset cost basis attached to an account's remaining Earn shares. */ export function calculateActiveCostBasis(options: calculateActiveCostBasis.Options) { const requests = new Map() let costBasis: bigint | undefined = 0n let shares = 0n const remove = (burned: bigint) => { if (burned > shares || shares === 0n) return undefined const removed = (() => { if (costBasis === undefined) return undefined return burned === shares ? costBasis : (costBasis * burned) / shares })() if (burned === shares) costBasis = 0n else if (costBasis !== undefined && removed !== undefined) costBasis -= removed shares -= burned return { costBasis: removed } } for (const action of options.actions) { if (action.kind === 'deposit') { if (costBasis !== undefined) costBasis += action.assets shares += action.shares continue } if (action.kind === 'unknown') { costBasis = undefined shares += action.shares continue } if (action.kind === 'cancel') { const restored = requests.get(action.requestId) if (restored === undefined || restored.shares !== action.shares) return undefined if (costBasis === undefined || restored.costBasis === undefined) costBasis = undefined else costBasis += restored.costBasis shares += restored.shares requests.delete(action.requestId) continue } const removed = remove(action.shares) if (removed === undefined) return undefined if (action.kind === 'request') requests.set(action.requestId, { costBasis: removed.costBasis, shares: action.shares }) } return shares === options.shareBalance && costBasis !== undefined ? costBasis : undefined } export declare namespace calculateActiveCostBasis { /** One indexed share or cost-basis mutation. */ export type Action = | { /** Action discriminator. */ kind: 'cancel' /** Async redemption request being cancelled. */ requestId: string /** Shares restored to the account. */ shares: bigint } | { /** Assets added to the account's cost basis. */ assets: bigint /** Action discriminator. */ kind: 'deposit' /** Shares issued for the deposit. */ shares: bigint } | { /** Action discriminator. */ kind: 'redeem' /** Shares removed from the account. */ shares: bigint } | { /** Action discriminator. */ kind: 'request' /** Async redemption request holding the removed basis. */ requestId: string /** Shares removed from the account. */ shares: bigint } | { /** Action discriminator. */ kind: 'unknown' /** Shares received without an asset-denominated cost basis. */ shares: bigint } /** Inputs for calculating the remaining cost basis. */ export type Options = { /** Indexed mutations in chain order. */ actions: readonly Action[] /** Share balance read at the same indexed block. */ shareBalance: bigint } } /** Reads the active asset cost basis for shares held at one indexed block. */ async function getActiveCostBasis(tidx: Tidx.Client, options: getActiveCostBasis.Options) { const result = await tidx.fetch({ engine: 'clickhouse', query: ` SELECT kind, request_id, shares, assets FROM ( SELECT 'deposit' AS kind, block_num, log_idx, '' AS request_id, toString(toUInt256(earnShares)) AS shares, toString(toUInt256(assets)) AS assets FROM Deposited WHERE address = '${options.vault}' AND receiver = '${options.account}' AND block_num <= ${options.blockNumber} UNION ALL SELECT 'redeem' AS kind, block_num, log_idx, '' AS request_id, toString(toUInt256(earnShares)) AS shares, '0' AS assets FROM Redeemed WHERE address = '${options.vault}' AND caller = '${options.account}' AND block_num <= ${options.blockNumber} UNION ALL SELECT 'redeem' AS kind, block_num, log_idx, '' AS request_id, toString(toUInt256(earnSharesBurned)) AS shares, '0' AS assets FROM WithdrewExact WHERE address = '${options.vault}' AND caller = '${options.account}' AND block_num <= ${options.blockNumber} UNION ALL SELECT 'request' AS kind, block_num, log_idx, "requestId" AS request_id, toString(toUInt256(earnShares)) AS shares, '0' AS assets FROM RedeemRequested WHERE address = '${options.vault}' AND requester = '${options.account}' AND block_num <= ${options.blockNumber} UNION ALL SELECT 'cancel' AS kind, cancelled.block_num AS block_num, cancelled.log_idx AS log_idx, cancelled."requestId" AS request_id, toString(toUInt256(cancelled.earnShares)) AS shares, '0' AS assets FROM RedeemCancelled AS cancelled INNER JOIN RedeemRequested AS requested ON requested.address = cancelled.address AND requested."requestId" = cancelled."requestId" WHERE cancelled.address = '${options.vault}' AND requested.requester = '${options.account}' AND cancelled.receiver = '${options.account}' AND cancelled.block_num <= ${options.blockNumber} AND requested.block_num <= ${options.blockNumber} UNION ALL SELECT 'unknown' AS kind, block_num, log_idx, '' AS request_id, toString(toUInt256(earnShares)) AS shares, '0' AS assets FROM VenueSharesDeposited WHERE address = '${options.vault}' AND receiver = '${options.account}' AND block_num <= ${options.blockNumber} UNION ALL SELECT 'unknown' AS kind, block_num, log_idx, '' AS request_id, toString(toUInt256(amount)) AS shares, '0' AS assets FROM token_transfers WHERE token = '${options.shareToken.toLowerCase()}' AND "to" = '${options.account}' AND "from" != '${zeroAddress}' AND "from" != '${options.vault}' AND block_num <= ${options.blockNumber} UNION ALL SELECT 'redeem' AS kind, block_num, log_idx, '' AS request_id, toString(toUInt256(amount)) AS shares, '0' AS assets FROM token_transfers WHERE token = '${options.shareToken.toLowerCase()}' AND "from" = '${options.account}' AND "to" != '${options.vault}' AND block_num <= ${options.blockNumber} ) AS actions ORDER BY block_num, log_idx ` as string, signatures: [ depositedSignature, redeemCancelledSignature, redeemRequestedSignature, redeemedSignature, venueSharesDepositedSignature, withdrewExactSignature, ], }) const actions: calculateActiveCostBasis.Action[] = [] for (const row of result.rows) { const kind = Value.toText(row['kind']) if (kind === 'deposit') { const assets = Value.toIntegerString(row['assets']) const shares = Value.toIntegerString(row['shares']) if (assets === undefined || shares === undefined) return undefined actions.push({ assets: BigInt(assets), kind, shares: BigInt(shares) }) continue } if (kind === 'redeem') { const shares = Value.toIntegerString(row['shares']) if (shares === undefined) return undefined actions.push({ kind, shares: BigInt(shares) }) continue } if (kind === 'cancel') { const requestId = Value.toText(row['request_id']) const shares = Value.toIntegerString(row['shares']) if (requestId === undefined || shares === undefined) return undefined actions.push({ kind, requestId, shares: BigInt(shares) }) continue } if (kind === 'request') { const requestId = Value.toText(row['request_id']) const shares = Value.toIntegerString(row['shares']) if (requestId === undefined || shares === undefined) return undefined actions.push({ kind, requestId, shares: BigInt(shares) }) continue } if (kind === 'unknown') { const shares = Value.toIntegerString(row['shares']) if (shares === undefined) return undefined actions.push({ kind, shares: BigInt(shares) }) continue } return undefined } return calculateActiveCostBasis({ actions, shareBalance: options.shareBalance }) } declare namespace getActiveCostBasis { type Options = { account: z.output blockNumber: number shareBalance: bigint shareToken: z.output vault: z.output } } /** Lists compatible deployed Earn vaults. */ export async function listVaults(c: Context, options: listVaults.Options) { const db = Db.get(c.get('dbCached')) const [records, snapshot] = await Promise.all([ core_EarnVaults.list(db, { chainId: options.chainId }), VerifiedTokens.snapshot(c, options.chainId), ]) const include = vaultInclude(options) const apyByVault = new Map() const registry = new Map(records.map((record) => [record.vaultAddress.toLowerCase(), record])) const decoded = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined let position = decoded ? ([Number(decoded[0]), Number(decoded[1])] as listVaults.Position) : undefined let hasMore = false let scanned = 0 const matches: listVaults.Match[] = [] while (matches.length <= options.limit && scanned < maxScannedDeployments) { const page = await queryDeployments(c, { asset: options.asset, chainId: options.chainId, cursor: position, limit: discoveryBatchLimit({ filtered: options.capability.length > 0 || options['engine.type'] !== undefined, limit: options.limit, matches: matches.length, remaining: maxScannedDeployments - scanned, }), }) hasMore = page.hasMore if (page.items.length === 0) { hasMore = false break } scanned += page.items.length const apyVaults = apyByVault.size === 0 && include.includes('apy') && options.capability.length === 0 && options['engine.type'] === undefined ? page.items .slice(0, options.limit - matches.length) .map(({ vaultAddress }) => vaultAddress) : [] // Rate boundaries and historical quotes depend only on candidate addresses, // so the unfiltered page can resolve them alongside live vault discovery. const [discovered, prefetchedApy] = await Promise.all([ Promise.all( page.items.map(async (item) => { const record = registry.get(item.vaultAddress.toLowerCase()) try { const discovered = await resolveVaultDiscovery(c, { chainId: options.chainId, include: discoveryInclude(options), vaultAddress: item.vaultAddress, }) const { discovery, zoneRoutes } = await resolveVaultRoutes(c, { capability: options.capability, discovery: discovered, include, record, }) return { discovery, position: item.position, record, zoneRoutes } } catch (cause) { if (!record && isIncompatible(cause)) return undefined throw cause } }), ), apyVaults.length > 0 ? resolvePageApy(c, { chainId: options.chainId, include, vaults: apyVaults, window: options['apy.window'], }) : undefined, ]) for (const [vault, apy] of prefetchedApy ?? []) apyByVault.set(vault, apy) for (const match of discovered) { if (!match || !matchesVaultFilters(match.discovery, options)) continue matches.push(match) } position = page.items.at(-1)!.position if (!hasMore) break } const page = matches.slice(0, options.limit) const discoveries = page.map(({ discovery }) => discovery) const missingApyVaults = include.includes('apy') ? discoveries.flatMap(({ vaultAddress }) => apyByVault.has(vaultAddress.toLowerCase()) ? [] : [vaultAddress], ) : [] const [missingApy, rates, tokens] = await Promise.all([ missingApyVaults.length > 0 ? resolvePageApy(c, { chainId: options.chainId, include, vaults: missingApyVaults, window: options['apy.window'], }) : undefined, resolvePageRates(c, { discoveries, include, oracle: options.oracle, snapshot }), resolvePageTokens(c, { chainId: options.chainId, discoveries, include, snapshot }), ]) for (const [vault, apy] of missingApy ?? []) apyByVault.set(vault, apy) const apy = include.includes('apy') ? apyByVault : undefined const data = await Promise.all( page.map((match) => resolveVault(c, { ...match, apy, include, rates, snapshot, tokens })), ) const last = matches[options.limit - 1] const nextPosition = matches.length > options.limit ? last?.position : hasMore ? position : undefined return { data, ...(rates ? { meta: { valuation: Valuation.pricing(rates, options.oracle) } } : {}), nextCursor: nextPosition ? Cursor.encode(nextPosition) : null, } } export declare namespace listVaults { /** One compatible deployment found while scanning TIDX. */ type Match = { discovery: EarnVaults.Discovery position: Position record?: RegistryRecord | undefined zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } /** Options for listing compatible Earn vaults. */ type Options = z.output & { chainId: z.output /** FX rate oracle backing TVL valuation. */ oracle: FxOracle.Oracle } /** TIDX deployment cursor position. */ type Position = [number, number] } /** Lists registry-curated Earn vaults. */ export async function listVerifiedVaults( c: Context, options: listVerifiedVaults.Options, ) { const db = Db.get(c.get('dbCached')) const [records, snapshot] = await Promise.all([ core_EarnVaults.list(db, { chainId: options.chainId }), VerifiedTokens.snapshot(c, options.chainId), ]) const decoded = options.cursor ? Cursor.decode(options.cursor, ['address']) : undefined const cursor = decoded?.[0]?.toString().toLowerCase() const candidates = records.filter( (record) => !cursor || record.vaultAddress.toLowerCase() > cursor, ) const include = vaultInclude(options) const matches: listVerifiedVaults.Match[] = [] let offset = 0 while (offset < candidates.length && matches.length <= options.limit) { const batchSize = discoveryBatchLimit({ filtered: options.capability.length > 0 || options['engine.type'] !== undefined, limit: options.limit, matches: matches.length, remaining: candidates.length - offset, }) const batch = candidates.slice(offset, offset + batchSize) offset += batch.length const discovered = await Promise.all( batch.map(async (record) => { const discovered = await resolveVaultDiscovery(c, { chainId: options.chainId, include: discoveryInclude(options), vaultAddress: Schema.Address.parse(record.vaultAddress.toLowerCase()), }) const { discovery, zoneRoutes } = await resolveVaultRoutes(c, { capability: options.capability, discovery: discovered, include, record, }) return { discovery, record, zoneRoutes } }), ) for (const match of discovered) { if (!matchesVaultFilters(match.discovery, options)) continue matches.push(match) } } const page = matches.slice(0, options.limit) const discoveries = page.map(({ discovery }) => discovery) const [apy, rates, tokens] = await Promise.all([ resolvePageApy(c, { chainId: options.chainId, include, vaults: discoveries.map(({ vaultAddress }) => vaultAddress), window: options['apy.window'], }), resolvePageRates(c, { discoveries, include, oracle: options.oracle, snapshot }), resolvePageTokens(c, { chainId: options.chainId, discoveries, include, snapshot }), ]) const data = await Promise.all( page.map(async (match) => Response.validated( schema.VerifiedVault, await resolveVault(c, { ...match, apy, include, rates, snapshot, tokens }), ), ), ) const last = matches[options.limit - 1] return { data, ...(rates ? { meta: { valuation: Valuation.pricing(rates, options.oracle) } } : {}), nextCursor: matches.length > options.limit && last ? Cursor.encode([last.record.vaultAddress]) : null, } } export declare namespace listVerifiedVaults { /** One verified vault and its current onchain state. */ type Match = { discovery: EarnVaults.Discovery record: RegistryRecord zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } /** Options for listing verified Earn vaults. */ type Options = z.output & { chainId: z.output /** FX rate oracle backing TVL valuation. */ oracle: FxOracle.Oracle } } /** Lists every vault where an account holds earn shares, ordered by vault address. */ export async function listPositions(c: Context, options: listPositions.Options) { const denomination = options['valuation.currency'] const db = Db.get(c.get('dbCached')) const recordsPromise = core_EarnVaults.list(db, { chainId: options.chainId }) // The verified page needs no index: the registry is the candidate set. const heldPromise = options.verified ? undefined : queryHeldVaults(c, { address: options.address, chainId: options.chainId }) // Observing these branches prevents unhandled rejections while the // denomination validates; the later await still propagates them. void recordsPromise.catch(() => undefined) void heldPromise?.catch(() => undefined) // Validation resolves before discovery results are consumed, so a dependency // failure cannot mask a caller error. if (denomination) await Valuation.ratesFor(c, { currencies: [], denomination, oracle: options.oracle }) const [records, held] = await Promise.all([recordsPromise, heldPromise]) const registry = new Map(records.map((record) => [record.vaultAddress.toLowerCase(), record])) const vaults = held ?? [...registry.keys()].sort() const decoded = options.cursor ? Cursor.decode(options.cursor, ['address']) : undefined const cursor = decoded?.[0]?.toString().toLowerCase() const candidates = vaults.filter((vault) => !cursor || vault > cursor) const matches: listPositions.Match[] = [] let offset = 0 // The deployment-scan bound also caps per-request position reads, so a long // exited-position history pages through candidates instead of probing them all. while ( offset < candidates.length && matches.length <= options.limit && offset < maxScannedDeployments ) { const batchSize = discoveryBatchLimit({ // Zero-share candidates are read and then rejected, so batches over-fetch. filtered: true, limit: options.limit, matches: matches.length, remaining: Math.min(candidates.length, maxScannedDeployments) - offset, }) const batch = candidates.slice(offset, offset + batchSize) offset += batch.length const read = await Timing.time(c, 'earn_positions', () => Promise.all( batch.map(async (vault) => { const record = registry.get(vault) try { const position = await readPosition(c, { address: options.address, chainId: options.chainId, vaultAddress: Schema.Address.parse(vault), }) return { position, record, vaultAddress: vault } } catch (cause) { if (!record && isIncompatible(cause)) return undefined throw cause } }), ), ) for (const match of read) { if (!match || match.position.shareBalance === 0n) continue matches.push(match) } } const page = matches.slice(0, options.limit) const indexedBlock = page.length > 0 && options.include.includes('earnings') ? await getLatestIndexedBlock(c.get('getTidx')(options.chainId)) : undefined const [cashFlows, tokens, snapshot] = await Promise.all([ indexedBlock ? getVaultLifetimeCashFlows(c.get('getTidx')(options.chainId), { account: options.address, blockNumber: indexedBlock.number, vaults: page.map(({ vaultAddress }) => Schema.Address.parse(vaultAddress)), }) : undefined, (async () => { const addresses = [ ...new Set(page.flatMap(({ position }) => [position.assetToken, position.shareToken])), ] const groups: Tokens.resolveTokens.ReturnType[] = [] for (let index = 0; index < addresses.length; index += tokenReadChunkSize) groups.push( await Tokens.resolveTokens(c, { addresses: addresses.slice(index, index + tokenReadChunkSize), chainId: options.chainId, include: ['token.verified'], }), ) return new Map(groups.flatMap((group) => [...group])) })(), denomination ? VerifiedTokens.snapshot(c, options.chainId) : undefined, ]) const rates = denomination && snapshot ? await Valuation.ratesFor(c, { currencies: page.flatMap(({ position }) => { const currency = snapshot.byAddress.get(position.assetToken.toLowerCase())?.currency return currency === undefined ? [] : [currency] }), denomination, oracle: options.oracle, }) : undefined const last = matches[options.limit - 1] // A scan-bound exit resumes from the last probed candidate so a truncated // page never hides positions deeper in the candidate list. const next = matches.length > options.limit ? last?.vaultAddress : offset < candidates.length ? candidates[offset - 1] : undefined return { data: page.map((match) => serializePosition({ cashFlows: cashFlows?.get(match.vaultAddress), denomination, position: match.position, rates, snapshot, tokens, vaultAddress: match.vaultAddress, verified: match.record !== undefined, }), ), ...(rates ? { meta: { valuation: Valuation.pricing(rates, options.oracle) } } : {}), nextCursor: next ? Cursor.encode([next]) : null, } } export declare namespace listPositions { /** One candidate vault holding a non-zero position. */ type Match = { position: Actions.earn.getPosition.ReturnValue record?: RegistryRecord | undefined vaultAddress: string } /** Options for listing an account's earn positions. */ type Options = z.output & { /** Account whose positions to list. */ address: z.output chainId: z.output /** FX rate oracle backing per-row valuation. */ oracle: FxOracle.Oracle } } /** Finds vaults whose earn share token the account has ever held, by vault address. */ async function queryHeldVaults( c: Context, options: queryHeldVaults.Options, ): Promise { const tidx = c.get('getTidx')(options.chainId) // Each vault's newest deployment binds its current share token; joining it // against the account's holder deltas server-side turns holdings into vault // candidates in one round trip, with no literal list to overflow the request. // Earn shares are factory-minted TIP-20s, so the `0x20c0` prefix bounds the // subquery. Stale delta rows are fine: the live read drops them. const result = await Timing.time(c, 'earn_positions_discovery', () => tidx.fetch({ chainId: options.chainId, engine: 'clickhouse', query: ` SELECT "earnVault" FROM ( SELECT "earnVault", "earnShare", row_number() OVER ( PARTITION BY lower("earnVault") ORDER BY block_num DESC, log_idx DESC ) AS deployment_rank FROM EarnStackDeployed ) WHERE deployment_rank = 1 AND lower("earnShare") IN ( SELECT DISTINCT lower(token) FROM address_holder_deltas WHERE holder = '${options.address}' AND startsWith(token, '0x20c0') ) ORDER BY lower("earnVault") ASC ` as string, signatures: [deployedSignature], }), ) const rows: readonly Record[] = result.rows return rows.flatMap((row) => { const vault = Schema.Address.safeParse(Value.toText(row['earnVault'])) return vault.success ? [vault.data.toLowerCase()] : [] }) } declare namespace queryHeldVaults { /** Inputs selecting an account's held earn share tokens. */ type Options = { address: z.output chainId: z.output } } /** Reads one account's live position in one vault. */ function readPosition( c: Context, options: readPosition.Options, ): Promise { return c .get('getClient')(options.chainId) .earn.getPosition({ account: options.address, vault: options.vaultAddress }) } declare namespace readPosition { /** Inputs selecting one vault position. */ type Options = { address: z.output chainId: z.output vaultAddress: z.output } } /** Serializes one live position into the address-scoped row contract. */ function serializePosition(options: serializePosition.Options) { const { position, tokens } = options const assetToken = getToken(tokens, position.assetToken) const shareToken = getToken(tokens, position.shareToken) const vaultAddress = Schema.Address.parse(options.vaultAddress.toLowerCase()) return { assetAmount: valueFor(position.value, assetToken), assetToken, id: vaultAddress, ...(options.cashFlows ? { lifetimeCashFlows: options.cashFlows } : {}), shareAmount: valueFor(position.shareBalance, shareToken), shareToken, ...(options.denomination ? { valuation: Valuation.valuationFor({ amount: position.value, denomination: options.denomination, rates: options.rates, token: options.snapshot?.byAddress.get(assetToken.address), }), } : {}), vaultAddress, verified: options.verified, } } declare namespace serializePosition { /** Values used to serialize one account position row. */ type Options = { /** Indexed lifetime cash flows, when requested. */ cashFlows?: z.output< typeof schema.getEarnAddressPositions.Response >['data'][number]['lifetimeCashFlows'] /** Currency the row valuation is denominated in (uppercase). */ denomination?: string | undefined position: Actions.earn.getPosition.ReturnValue /** Consulted FX rate set, or undefined when unavailable or unnecessary. */ rates: Awaited> /** Curated token snapshot backing valuation, present when requested. */ snapshot?: VerifiedTokens.Snapshot | undefined tokens: Tokens.resolveTokens.ReturnType vaultAddress: string verified: boolean } } /** Reads complete protocol cash flows for one account and a set of vaults. */ export async function getVaultLifetimeCashFlows( tidx: Tidx.Client, options: getVaultLifetimeCashFlows.Options, ) { const vaults = [...new Set(options.vaults.map((vault) => vault.toLowerCase()))] const cashFlows = new Map() if (vaults.length === 0) return cashFlows const addresses = vaults.map((vault) => `'${vault}'`).join(', ') const seeds = vaults .map( (vault) => ` SELECT '${vault}' AS vault, toUInt256(0) AS deposited_assets, toUInt256(0) AS finalized_assets, toUInt256(0) AS redeemed_assets, toUInt64(0) AS venue_deposits, toUInt256(0) AS withdrew_exact`, ) .join('\n UNION ALL') const result = await tidx.fetch({ engine: 'clickhouse', query: ` SELECT vault, toString(sum(deposited_assets)) AS deposited_assets, toString(sum(finalized_assets)) AS finalized_assets, toString(sum(redeemed_assets)) AS redeemed_assets, sum(venue_deposits) AS venue_deposits, toString(sum(withdrew_exact)) AS withdrew_exact FROM ( ${seeds} UNION ALL SELECT address AS vault, sum(toUInt256(assets)) AS deposited_assets, toUInt256(0) AS finalized_assets, toUInt256(0) AS redeemed_assets, toUInt64(0) AS venue_deposits, toUInt256(0) AS withdrew_exact FROM Deposited WHERE address IN (${addresses}) AND receiver = '${options.account}' AND block_num > ${beforeFirstBlockNumber} AND block_num <= ${options.blockNumber} GROUP BY address UNION ALL SELECT finalized.address AS vault, toUInt256(0) AS deposited_assets, sum(toUInt256(finalized.assets)) AS finalized_assets, toUInt256(0) AS redeemed_assets, toUInt64(0) AS venue_deposits, toUInt256(0) AS withdrew_exact FROM RedeemFinalized AS finalized INNER JOIN RedeemRequested AS requested ON requested.address = finalized.address AND requested."requestId" = finalized."requestId" WHERE finalized.address IN (${addresses}) AND requested.requester = '${options.account}' AND finalized.block_num > ${beforeFirstBlockNumber} AND finalized.block_num <= ${options.blockNumber} AND requested.block_num <= ${options.blockNumber} GROUP BY finalized.address UNION ALL SELECT address AS vault, toUInt256(0) AS deposited_assets, toUInt256(0) AS finalized_assets, sum(toUInt256(assets)) AS redeemed_assets, toUInt64(0) AS venue_deposits, toUInt256(0) AS withdrew_exact FROM Redeemed WHERE address IN (${addresses}) AND caller = '${options.account}' AND block_num > ${beforeFirstBlockNumber} AND block_num <= ${options.blockNumber} GROUP BY address UNION ALL SELECT address AS vault, toUInt256(0) AS deposited_assets, toUInt256(0) AS finalized_assets, toUInt256(0) AS redeemed_assets, count() AS venue_deposits, toUInt256(0) AS withdrew_exact FROM VenueSharesDeposited WHERE address IN (${addresses}) AND receiver = '${options.account}' AND block_num > ${beforeFirstBlockNumber} AND block_num <= ${options.blockNumber} GROUP BY address UNION ALL SELECT address AS vault, toUInt256(0) AS deposited_assets, toUInt256(0) AS finalized_assets, toUInt256(0) AS redeemed_assets, toUInt64(0) AS venue_deposits, sum(toUInt256(assets)) AS withdrew_exact FROM WithdrewExact WHERE address IN (${addresses}) AND caller = '${options.account}' AND block_num > ${beforeFirstBlockNumber} AND block_num <= ${options.blockNumber} GROUP BY address ) AS histories GROUP BY vault ` as string, signatures: [ depositedSignature, redeemFinalizedSignature, redeemRequestedSignature, redeemedSignature, venueSharesDepositedSignature, withdrewExactSignature, ], }) for (const row of result.rows) { const vault = Schema.Address.safeParse(Value.toText(row['vault'])) const deposited = Value.toIntegerString(row['deposited_assets']) const finalized = Value.toIntegerString(row['finalized_assets']) const redeemed = Value.toIntegerString(row['redeemed_assets']) const venueDeposits = Value.toNumber(row['venue_deposits']) const withdrewExact = Value.toIntegerString(row['withdrew_exact']) if ( !vault.success || deposited === undefined || finalized === undefined || redeemed === undefined || venueDeposits === undefined || withdrewExact === undefined ) throw new Error('TIDX returned invalid Earn lifetime cash flows.') cashFlows.set( vault.data.toLowerCase(), venueDeposits > 0 ? { status: 'incomplete_history' } : { status: 'complete', totalDeposited: deposited, totalWithdrawn: ( BigInt(finalized) + BigInt(redeemed) + BigInt(withdrewExact) ).toString(), }, ) } if (cashFlows.size !== vaults.length) throw new Error('TIDX returned incomplete Earn lifetime cash flows.') return cashFlows } export declare namespace getVaultLifetimeCashFlows { /** Lifetime cash-flow state for one vault. */ type CashFlows = NonNullable< z.output['data'][number]['lifetimeCashFlows'] > /** Indexed boundary and account whose protocol cash flows are read. */ type Options = { account: z.output blockNumber: number vaults: readonly z.output[] } } /** Renders a base-unit amount in the token's display currency and precision. */ function valueFor(amount: bigint, token: z.output) { return { amount: amount.toString(), currency: token.currency, decimals: token.decimals, formatted: core_Value.format(amount, token.decimals), } } /** Gets one compatible Earn vault. */ export async function getVault(c: Context, options: getVault.Options) { const db = Db.get(c.get('dbCached')) const [record, snapshot] = await Promise.all([ core_EarnVaults.get(db, { chainId: options.chainId, vaultAddress: options.vaultAddress, }), VerifiedTokens.snapshot(c, options.chainId), ]) try { const discovered = await resolveVaultDiscovery(c, { chainId: options.chainId, include: discoveryInclude({ capability: [], include: options.include }), vaultAddress: options.vaultAddress, }) const include = vaultInclude(options) const { discovery, zoneRoutes } = await resolveVaultRoutes(c, { capability: [], discovery: discovered, include, record, }) const discoveries = [discovery] const [apy, rates, tokens] = await Promise.all([ resolvePageApy(c, { chainId: options.chainId, include, vaults: discoveries.map(({ vaultAddress }) => vaultAddress), window: options['apy.window'], }), resolvePageRates(c, { discoveries, include, oracle: options.oracle, snapshot }), resolvePageTokens(c, { chainId: options.chainId, discoveries, include, snapshot }), ]) return { ...(await resolveVault(c, { apy, discovery, include, rates, record, snapshot, tokens, zoneRoutes, })), ...(rates ? { meta: { valuation: Valuation.pricing(rates, options.oracle) } } : {}), } } catch (cause) { if (cause instanceof EarnVaults.NotFoundError || (!record && isIncompatible(cause))) throw new NotFoundError() throw cause } } export declare namespace getVault { /** Options for getting one Earn vault. */ type Options = { /** Canonical rate window the vault's APY covers. */ 'apy.window': z.output['apy.window'] chainId: z.output include: z.output['include'] /** FX rate oracle backing TVL valuation. */ oracle: FxOracle.Oracle vaultAddress: z.output } } // Selecting a rate window is a request for the rate it measures, mirroring how // a capability filter promotes `capabilities`. function vaultInclude(options: vaultInclude.Options) { const { include } = options if (options['apy.window'] === undefined || include.includes('apy')) return include return [...include, 'apy' as const] } declare namespace vaultInclude { type Options = Pick, 'apy.window' | 'include'> } /** Builds an Earn-vault cache key partitioned by Zone-visible API keys. */ export function vaultCacheKey( c: Context, query: query, ): string { const key = Cache.urlKey(c, query) const url = new URL(key) const includesRoutes = url.searchParams .getAll('include') .some((include) => include === 'zone' || include === 'zones') if (!includesRoutes) return key const principal = Auth.getPrincipal(c) if (principal?.type !== 'api_key') return key url.searchParams.set('principal', `${principal.type}:${principal.id}`) return url.toString() } function discoveryInclude( options: discoveryInclude.Options, ): readonly EarnVaults.resolve.Include[] { const includeCapabilities = options.include.includes('capabilities') || options.include.includes('zone') || options.capability.length > 0 return [ ...(options.include.includes('access') ? (['access'] as const) : []), ...(includeCapabilities ? (['capabilities'] as const) : []), ] } declare namespace discoveryInclude { type Options = Pick, 'capability' | 'include'> } async function resolveVaultDiscovery( c: Context, options: resolveVaultDiscovery.Options, ) { return Store.memoize( () => Timing.time(c, 'earn_vault_discovery', () => EarnVaults.resolve({ getClient: c.get('getClient'), include: options.include, input: { chainId: options.chainId, vaultAddress: options.vaultAddress, }, zones: [...c.get('zones').values()], }), ), { key: `earn-vault:v3:${options.chainId}:${options.vaultAddress.toLowerCase()}:include:${options.include.join(',') || 'base'}`, store: c.get('store'), // Matches the routes' `state` policy `max-age` so the two cache layers do // not compound staleness on live vault state. ttl: Ttl.seconds(30), }, ) } declare namespace resolveVaultDiscovery { type Options = { chainId: z.output include: readonly EarnVaults.resolve.Include[] vaultAddress: z.output } } async function resolveVaultRoutes( c: Context, options: resolveVaultRoutes.Options, ): Promise { const { discovery, record } = options if (!record || record.zones.length === 0) return { discovery, zoneRoutes: [] } const principal = Auth.getPrincipal(c) const scopes = principal?.type === 'api_key' ? principal.apiKey.scopes : undefined const includesRoutes = options.include.includes('zone') || options.include.includes('zones') const needsCapabilities = options.include.includes('capabilities') || options.capability.includes('privateRouting') || options.capability.includes('routerSwaps') const visibleRoutes = record.zones.filter((route) => hasZoneAccess({ chainId: route.chainId, scopes }), ) // Capabilities describe the vault; API-key scopes limit only the route metadata. const routes = needsCapabilities ? record.zones : includesRoutes ? visibleRoutes : [] if (routes.length === 0) return { discovery, zoneRoutes: [] } const resolved = await EarnVaults.resolveZoneRoutes({ discovery, getClient: c.get('getClient'), routes, zones: [...c.get('zones').values()], }) const capabilities = discovery.capabilities if (needsCapabilities && !capabilities) throw new Error('Earn vault capabilities were not resolved.') return { discovery: capabilities ? { ...discovery, capabilities: EarnVaults.addZoneRouteCapabilities(capabilities) } : discovery, zoneRoutes: includesRoutes ? resolved.filter((route) => hasZoneAccess({ chainId: route.chainId, scopes })) : [], } } declare namespace resolveVaultRoutes { type Options = { capability: z.output['capability'] discovery: EarnVaults.Discovery include: z.output['include'] record?: RegistryRecord | undefined } type Result = { discovery: EarnVaults.Discovery zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } } // Measuring a rate costs indexer boundary queries plus historical quotes against // an archive node, so an unrequested rate reaches neither. function resolvePageApy(c: Context, options: resolvePageApy.Options) { if (!options.include.includes('apy')) return undefined return Timing.time(c, 'earn_apy', () => EarnRates.resolve({ chainId: options.chainId, getClient: c.get('getClient'), getTidx: c.get('getTidx'), store: c.get('store'), vaults: options.vaults, window: options.window, }), ) } declare namespace resolvePageApy { type Options = { chainId: z.output include: z.output['include'] vaults: readonly z.output[] window: z.output['apy.window'] } } // The TVL denomination is a fixed constant, not caller input, so a rate set // lacking it leaves `tvl` unpriced instead of failing the whole vault read. function resolvePageRates(c: Context, options: resolvePageRates.Options) { // Valuing total assets is the only reason this read consults the oracle. if (!options.include.includes('tvl')) return undefined return Valuation.ratesFor(c, { currencies: options.discoveries.flatMap((discovery) => { const currency = options.snapshot.byAddress.get(discovery.assetToken.address)?.currency return currency === undefined ? [] : [currency] }), denomination: tvlDenomination, oracle: options.oracle, }).catch((cause) => { if (cause instanceof Valuation.UnsupportedDenominationError) return undefined throw cause }) } declare namespace resolvePageRates { type Options = { discoveries: readonly EarnVaults.Discovery[] include: z.output['include'] oracle: FxOracle.Oracle snapshot: VerifiedTokens.Snapshot } } async function resolvePageTokens(c: Context, options: resolvePageTokens.Options) { const metadata = new Map( options.discoveries.flatMap(({ assetToken, shareToken }) => [ [Schema.Address.parse(assetToken.address.toLowerCase()), assetToken] as const, [Schema.Address.parse(shareToken.address.toLowerCase()), shareToken] as const, ]), ) return Tokens.resolveTokens(c, { addresses: [...metadata.keys()], chainId: options.chainId, include: [ 'token.verified', ...(options.include.includes('token.logoUri') ? (['token.logoUri'] as const) : []), ], metadata, snapshot: options.snapshot, }) } declare namespace resolvePageTokens { type Options = { chainId: z.output discoveries: readonly EarnVaults.Discovery[] include: z.output['include'] snapshot: VerifiedTokens.Snapshot } } async function resolveVault(c: Context, options: resolveVault.Options) { const { discovery, record } = options const principal = Auth.getPrincipal(c) const scopes = principal?.type === 'api_key' ? principal.apiKey.scopes : undefined const zones = [...c.get('zones').values()].filter((zone) => hasZoneAccess({ chainId: zone.id, scopes }), ) const valued = options.include.includes('tvl') ? Valuation.valuationFor({ amount: BigInt(discovery.state.totalAssets), denomination: tvlDenomination, rates: options.rates, token: options.snapshot.byAddress.get(discovery.assetToken.address), }) : undefined const liquidityValue = options.include.includes('tvl') && discovery.instantLiquidity !== null ? Valuation.valuationFor({ amount: BigInt(discovery.instantLiquidity), denomination: tvlDenomination, rates: options.rates, token: options.snapshot.byAddress.get(discovery.assetToken.address), }) : undefined return serializeVault({ apy: options.apy?.get(discovery.vaultAddress) ?? null, assetToken: getToken(options.tokens, discovery.assetToken.address), discovery, include: options.include, instantLiquidityValue: liquidityValue ? { amount: core_Value.from(liquidityValue.amount, tvlDecimals).toString(), currency: liquidityValue.currency, decimals: tvlDecimals, formatted: liquidityValue.amount, } : null, record, shareToken: getToken(options.tokens, discovery.shareToken.address), tvl: valued ? { amount: core_Value.from(valued.amount, tvlDecimals).toString(), currency: valued.currency, decimals: tvlDecimals, formatted: valued.amount, } : null, zoneRoutes: options.zoneRoutes, zones, }) } declare namespace resolveVault { type Options = { apy: ReadonlyMap | undefined discovery: EarnVaults.Discovery include: z.output['include'] rates: Awaited> record?: RegistryRecord | undefined snapshot: VerifiedTokens.Snapshot tokens: Tokens.resolveTokens.ReturnType zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } } /** Serializes resolved onchain and curated fields into the shared vault contract. */ export function serializeVault(options: serializeVault.Options): z.output { const { discovery, record } = options if (options.include.includes('access') && !discovery.access) throw new Error('Earn vault access was not resolved.') if (options.include.includes('capabilities') && !discovery.capabilities) throw new Error('Earn vault capabilities were not resolved.') const vaultAddress = Schema.Address.parse(discovery.vaultAddress.toLowerCase()) return Response.validated(schema.Vault, { ...(options.include.includes('access') ? { access: discovery.access } : {}), ...(options.include.includes('apy') ? { apy: options.apy } : {}), assetToken: options.assetToken, ...(options.include.includes('capabilities') ? { capabilities: discovery.capabilities } : {}), description: record?.description ?? null, engine: { address: discovery.engine.address.toLowerCase(), type: discovery.engine.type, venue: discovery.engine.venue?.toLowerCase() ?? null, }, id: vaultAddress, instantLiquidity: discovery.instantLiquidity, ...(options.include.includes('tvl') ? { instantLiquidityValue: options.instantLiquidityValue } : {}), label: record?.label ?? options.shareToken.name, sharePrice: discovery.sharePrice === null ? null : { amount: discovery.sharePrice, currency: options.assetToken.currency, decimals: options.assetToken.decimals, formatted: core_Value.format(BigInt(discovery.sharePrice), options.assetToken.decimals), }, shareToken: options.shareToken, slug: record?.slug ?? null, state: discovery.state, ...(options.include.includes('tvl') ? { tvl: options.tvl } : {}), vaultAddress, verified: record !== undefined, ...(options.include.includes('zone') ? { zone: record ? resolveZone({ discovery, record, zoneRoutes: options.zoneRoutes, zones: options.zones, }) : null, } : {}), ...(options.include.includes('zones') ? { zones: record ? options.zoneRoutes : [] } : {}), }) } export declare namespace serializeVault { /** Values used to serialize one resolved Earn vault. */ type Options = { /** Measured rate, or null when unavailable; omitted unless `include` carries `apy`. */ apy: EarnRates.Apy | null /** Resolved asset token. */ assetToken: z.output /** Live onchain vault state. */ discovery: EarnVaults.Discovery /** Optional vault fields selected by the request. */ include: z.output['include'] /** USD value of instant liquidity, or null when unavailable; omitted unless `include` carries `tvl`. */ instantLiquidityValue: z.output['instantLiquidityValue'] /** Curated registry row, when verified. */ record?: core_EarnVaults.Record | undefined /** Resolved share token. */ shareToken: z.output /** USD value of the assets, or null when unpriced; omitted unless `include` carries `tvl`. */ tvl: z.output['tvl'] /** Verified Zone routes with immutable router bindings. */ zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] /** Private Zones available to the vault. */ zones: readonly Chain[] } } function resolveZone(options: resolveZone.Options): z.output { if (options.record.zones.length > 0) { if (options.zoneRoutes.length !== 1) return null const route = options.zoneRoutes[0]! return { chainId: route.chainId, inputTokens: [...route.inputTokens], name: route.name, outputTokens: [...route.outputTokens], } } if (!options.discovery.capabilities?.privateRouting) return null const hasInputs = options.record.privateInputTokens.length > 0 const hasOutputs = options.record.privateOutputTokens.length > 0 if (!hasInputs && !hasOutputs) return null if (hasInputs !== hasOutputs) throw new Error('Private input and output tokens must both be configured.') const configured = options.zones.filter((zone) => zone.sourceId === options.record.chainId) if (configured.length === 0) return null if (configured.length !== 1) throw new Error(`Expected one Earn Zone for chain ${options.record.chainId}.`) const zone = configured[0]! return { chainId: zone.id, inputTokens: options.record.privateInputTokens.map((token) => Schema.Address.parse(token.toLowerCase()), ), name: zone.name, outputTokens: options.record.privateOutputTokens.map((token) => Schema.Address.parse(token.toLowerCase()), ), } } declare namespace resolveZone { type Options = { discovery: EarnVaults.Discovery record: RegistryRecord zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] zones: readonly Chain[] } } function getToken( tokens: Tokens.resolveTokens.ReturnType, address: z.output, ): z.output { const token = tokens.get(Schema.Address.parse(address.toLowerCase())) if (!token) throw new Error(`Token metadata was not resolved for ${address}.`) return Response.validated(Tokens.schema.Token, token) } async function queryDeployments( c: Context, options: queryDeployments.Options, ): Promise { const filters: string[] = [] if (options.asset) filters.push(`lower(asset) = '${options.asset.toLowerCase()}'`) if (options.cursor) filters.push( Cursor.keyset([ { literal: Cursor.literal(options.cursor[0], 'int'), name: 'block_num', order: 'desc', }, { literal: Cursor.literal(options.cursor[1], 'int'), name: 'log_idx', order: 'desc', }, ]), ) // Deduplicate before outer filters so every vault is filtered by its latest deployment. const result = await c .get('getTidx')(options.chainId) .fetch({ chainId: options.chainId, engine: 'clickhouse', query: ` SELECT "earnVault", asset, block_num, log_idx FROM ( SELECT "earnVault", asset, block_num, log_idx FROM EarnStackDeployed ORDER BY block_num DESC, log_idx DESC LIMIT 1 BY lower("earnVault") ) ${filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''} ORDER BY block_num DESC, log_idx DESC LIMIT ${options.limit + 1} ` as string, signatures: [deployedSignature], }) const rows: readonly Record[] = result.rows const items = rows.slice(0, options.limit).map((row) => { const vaultAddress = Schema.Address.safeParse(Value.toText(row['earnVault'])) const asset = Schema.Address.safeParse(Value.toText(row['asset'])) const blockNumber = Value.toNumber(row['block_num']) const logIndex = Value.toNumber(row['log_idx']) if ( !vaultAddress.success || !asset.success || blockNumber === undefined || logIndex === undefined ) throw new Error('TIDX returned an invalid Earn deployment row.') return { asset: asset.data, position: [blockNumber, logIndex] as queryDeployments.Position, vaultAddress: vaultAddress.data, } }) return { hasMore: rows.length > options.limit, items } } declare namespace queryDeployments { type Item = { asset: z.output position: Position vaultAddress: z.output } type Options = { asset?: z.output | undefined chainId: z.output cursor?: Position | undefined limit: number } type Position = [number, number] type Result = { hasMore: boolean items: readonly Item[] } } /** Tests live vault state against the shared collection filters. */ export function matchesVaultFilters( discovery: EarnVaults.Discovery, options: matchesVaultFilters.Options, ): boolean { if (options.asset && discovery.assetToken.address.toLowerCase() !== options.asset.toLowerCase()) return false const capabilities = discovery.capabilities if (options.capability.length > 0 && !capabilities) throw new Error('Earn vault capabilities were not resolved.') if (capabilities && !options.capability.every((name) => capabilities[name])) return false if (options['engine.type'] && discovery.engine.type !== options['engine.type']) return false return true } export declare namespace matchesVaultFilters { /** Filters shared by the all-vault and verified-vault collections. */ type Options = Pick< z.output, 'asset' | 'capability' | 'engine.type' > } /** Returns whether a failed read proves the address is not a compatible Earn vault. */ export function isIncompatible(cause: unknown) { if (cause instanceof EarnVaults.NotFoundError) return true if (cause instanceof EarnVaults.VerificationError) return cause.result.status === 'mismatch' if (!(cause instanceof ContractFunctionExecutionError)) return false return Boolean( cause.walk( (error) => error instanceof ContractFunctionRevertedError || error instanceof ContractFunctionZeroDataError, ), ) } type RegistryRecord = Awaited>[number] /** Thrown when no compatible Earn vault exists at the requested address. */ export class NotFoundError extends Error { override name = 'Earn.NotFoundError' } /** Thrown when a historical position boundary is not indexed. */ class IndexedBlockNotFoundError extends Error { override name = 'Earn.IndexedBlockNotFoundError' constructor(asOf: string) { super(`No indexed block exists at or before ${asOf}.`) } } class IndexedCoverageError extends Error { override name = 'Earn.IndexedCoverageError' constructor(asOf: string) { super(`Indexed history does not yet cover ${asOf}.`) } }