import { ZoneId } from 'ox/tempo' import { ContractFunctionExecutionError, ContractFunctionRevertedError, ContractFunctionZeroDataError, isAddressEqual, parseAbi, } from 'viem' import type { Address, Chain } from 'viem' import { Abis, Addresses } from 'viem/tempo' import * as z from 'zod/mini' import * as Schema from './Schema.js' import * as Viem from './Viem.js' /** Zod schemas owned by the Earn vault resolver. */ export namespace schema { const access = z .strictObject({ status: z .enum(['allowlisted', 'open']) .check(z.describe('Vault share-token access status.'), z.meta({ examples: ['open'] })), }) .check(z.describe('Vault share-token access.')) const capabilities = z .strictObject({ asyncRedeem: z .boolean() .check( z.describe('Whether queued redemptions are supported.'), z.meta({ examples: [false] }), ), boundedRedeem: z .boolean() .check( z.describe('Whether redemption accepts a minimum asset bound.'), z.meta({ examples: [true] }), ), deposit: z .boolean() .check(z.describe('Whether asset deposits are supported.'), z.meta({ examples: [true] })), exactWithdraw: z .boolean() .check( z.describe('Whether exact asset withdrawals are supported.'), z.meta({ examples: [true] }), ), inKindDeposit: z .boolean() .check( z.describe('Whether venue-share deposits are supported.'), z.meta({ examples: [true] }), ), privateRouting: z .boolean() .check(z.describe('Whether private routing is supported.'), z.meta({ examples: [false] })), redeem: z .boolean() .check( z.describe('Whether immediate redemptions are supported.'), z.meta({ examples: [true] }), ), routerSwaps: z .boolean() .check( z.describe('Whether router token swaps are supported.'), z.meta({ examples: [false] }), ), }) .check(z.describe('Actions supported by an Earn vault.')) const engineType = z .nullable(z.enum(['erc4626', 'veda'])) .check( z.describe('Engine type inferred from supported actions.'), z.meta({ examples: ['erc4626'] }), ) const state = z .strictObject({ depositsPaused: z .boolean() .check(z.describe('Whether new deposits are paused.'), z.meta({ examples: [false] })), engineShares: Schema.DecimalString.check( z.describe('Venue shares held for the vault.'), z.meta({ examples: ['1000000'] }), ), feesActive: z .boolean() .check( z.describe('Whether fees are configured and not emergency-disabled.'), z.meta({ examples: [true] }), ), isAccountingAligned: z .boolean() .check( z.describe('Whether Earn share supply matches its asset backing.'), z.meta({ examples: [true] }), ), openRedeemRequestCount: z .number() .check( z.int(), z.nonnegative(), z.describe('Queued redemptions still awaiting settlement.'), z.meta({ examples: [0] }), ), totalAssets: Schema.DecimalString.check( z.describe('Asset value of the active backing.'), z.meta({ examples: ['1000000'] }), ), totalEarnShares: Schema.DecimalString.check( z.describe('Active Earn share supply.'), z.meta({ examples: ['1000000'] }), ), }) .check(z.describe('Live vault accounting state.')) const metadata = z .strictObject({ address: Schema.Address.check( z.describe('Token contract address.'), z.meta({ examples: ['0x20c000000000000000000000ff04042ee92fd449'] }), ), currency: z .string() .check(z.describe('Token display currency.'), z.meta({ examples: ['USD'] })), decimals: z .number() .check( z.int(), z.nonnegative(), z.maximum(255), z.describe('Token decimal precision.'), z.meta({ examples: [6] }), ), name: z .string() .check( z.minLength(1), z.describe('Token name.'), z.meta({ examples: ['Bridge Test PATHUSD'] }), ), symbol: z .string() .check(z.minLength(1), z.describe('Token symbol.'), z.meta({ examples: ['btPATHUSD'] })), }) .check(z.describe('Token address and metadata resolved onchain.')) /** Schema selecting one onchain Earn vault. */ export const ResolveInput = z .strictObject({ chainId: Schema.ChainId.check( z.describe('Tempo chain id containing the vault.'), z.meta({ examples: [42431] }), ), vaultAddress: Schema.Address.check( z.describe('Earn vault contract address.'), z.meta({ examples: ['0x4f94590b636f5878bce585e82379de81e1ec174f'] }), ), }) .check(z.describe('Onchain identity of an Earn vault.')) /** Schema for one curated Earn route through a private Zone. */ export 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'] }), ), }) .check(z.describe('Curated Earn route through one private Zone.')) /** Schema for live state resolved from one Earn vault. */ export const Discovery = z .strictObject({ access: z.optional(access).check(z.describe('Vault share-token access, when requested.')), assetToken: metadata, capabilities: z .optional(capabilities) .check(z.describe('Actions supported by the vault, when requested.')), chainId: Schema.ChainId.check( z.describe('Tempo chain id containing the vault.'), z.meta({ examples: [42431] }), ), engine: z .strictObject({ address: Schema.Address.check( z.describe('Current vault engine address.'), z.meta({ examples: ['0xd85fc943333a6cd7c0e3391acd54fc4e572f0baf'] }), ), type: engineType, venue: z .nullable( Schema.Address.check( z.describe('Yield venue bound to the engine.'), z.meta({ examples: ['0xa8b4f0e69cd4b0e56b676343f02acd8c3b355322'] }), ), ) .check(z.describe('Yield venue, or null when the engine does not expose one.')), }) .check(z.describe('Current vault engine.')), instantLiquidity: z .nullable( Schema.DecimalString.check( z.describe('Assets the venue would release right now, in asset base units.'), z.meta({ examples: ['1000000'] }), ), ) .check( z.describe( 'Synchronously redeemable assets, or null when the engine cannot report a reliable number.', ), ), sharePrice: z .nullable( Schema.DecimalString.check( z.describe('Assets returned for one whole Earn share, in asset base units.'), z.meta({ examples: ['1000000'] }), ), ) .check( z.describe('Assets per whole Earn share, or null when the engine cannot quote an exit.'), ), shareToken: metadata, state, vaultAddress: Schema.Address.check( z.describe('Earn vault contract address.'), z.meta({ examples: ['0x4f94590b636f5878bce585e82379de81e1ec174f'] }), ), }) .check(z.describe('Live state resolved from an Earn vault.')) } /** Live state resolved from an Earn vault. */ export type Discovery = z.output /** Onchain identity of an Earn vault. */ export type ResolveInput = z.output /** Curated Earn route through one private Zone. */ export type ZoneRoute = z.output /** Curated Zone route with immutable router bindings resolved onchain. */ export type ResolvedZoneRoute = ZoneRoute & { /** Tokens accepted by private deposits. */ inputTokens: readonly Address[] /** Configured private Zone name. */ name: string /** Tokens returned by private redemptions. */ outputTokens: readonly Address[] } /** Adds capabilities proven by a verified private Zone route. */ export function addZoneRouteCapabilities( capabilities: NonNullable, ): NonNullable { return { ...capabilities, privateRouting: true, routerSwaps: true } } /** Derives a Zone ID from any supported Zone chain-ID scheme. */ export function deriveZoneId(chainId: number): number { for (const sourceId of [4_217, 42_431] satisfies readonly ZoneId.SourceId[]) { const zoneId = ZoneId.fromChainId(chainId, sourceId) if (zoneId >= 0 && ZoneId.toChainId(zoneId, sourceId) === chainId) return zoneId } throw new InvalidRegistryError(`Chain ${chainId} does not use a supported Zone chain-ID scheme.`) } /** Stable code describing an onchain verification issue. */ export type VerificationIssueCode = | 'chain-mismatch' | 'engine-asset-mismatch' | 'policy-unsupported' | 'read-failed' | 'zone-deployment-block-mismatch' | 'zone-flow-unsupported' | 'zone-id-mismatch' | 'zone-router-invalid' | 'zone-share-mismatch' | 'zone-vault-asset-mismatch' | 'zone-vault-mismatch' /** One onchain verification issue. */ export type VerificationIssue = { /** Observed value, when available. */ actual?: string | undefined /** Stable machine-readable issue code. */ code: VerificationIssueCode /** Expected value, when available. */ expected?: string | undefined /** Human-readable issue detail. */ message: string } /** Token and vault state observed during verification. */ export type VerificationSnapshot = { /** Asset token observed onchain. */ assetToken?: Discovery['assetToken'] | undefined /** Whether vault supply matches its engine backing. */ isSynced?: boolean | undefined /** Share token observed onchain. */ shareToken?: Discovery['shareToken'] | undefined } type VerificationMismatch = { /** Issues found while reading the deployment. */ issues: readonly VerificationIssue[] /** Token and vault state observed during verification. */ snapshot: VerificationSnapshot /** Indicates that onchain state is incompatible. */ status: 'mismatch' } type VerificationUnavailable = { /** Issue that prevented verification. */ issues: readonly VerificationIssue[] /** Indicates that verification could not complete. */ status: 'unavailable' } type VerificationFailure = VerificationMismatch | VerificationUnavailable type Client = Pick type EngineCapabilities = { asyncRedeem: boolean exactWithdraw: boolean inKindDeposit: boolean syncRedeem: boolean } type VaultBindings = { asset: Address capabilities: EngineCapabilities depositsPaused: boolean engine: Address engineShares: bigint feesActive: boolean isSynced: boolean openRedeemRequestCount: bigint shareSupply: bigint shareToken: Address totalAssets: bigint } type RouterCapabilities = { deposit: boolean redeem: boolean } type ZoneRouteBindings = { allowedZoneId: number deposit: boolean earnShare: Address earnVault: Address privateAsset: Address redeem: boolean vaultAsset: Address } type TokenMetadata = Omit const engineBindingsAbi = parseAbi(['function vault() view returns (address)']) const venueLiquidityAbi = parseAbi(['function maxWithdraw(address owner) view returns (uint256)']) /** * Shares quoted to derive a share price. `previewRedeem` is linear in shares, so * a large quote rescales to one whole share without base-unit quantization. */ const quotedShares = 10n ** 18n const accessFlights = new WeakMap>>>() const chainIds = new WeakMap>() const fullInclude = ['access', 'capabilities'] as const const tokenMetadataFlights = new WeakMap>>() const transferPolicyIdFlights = new WeakMap>>() /** Resolves one Earn vault from its onchain identity. */ export function resolve(options: resolve.ProjectedOptions): Promise /** Resolves every optional Earn vault field. */ export function resolve(options: resolve.Options): Promise export async function resolve( options: resolve.Options | resolve.ProjectedOptions, ): Promise { const input = parseInput(options.input) const include = 'include' in options ? options.include : fullInclude const zones = options.zones.filter((zone) => zone.sourceId === input.chainId) return resolveOnchain(options.getClient(input.chainId), { include, input, zones }) } export declare namespace resolve { /** Fully resolved Earn vault state. */ type CompleteDiscovery = Discovery & { /** Vault share-token access. */ access: NonNullable /** Actions supported by the vault. */ capabilities: NonNullable } /** Optional onchain fields to resolve. */ type Include = 'access' | 'capabilities' /** Options for resolving one Earn vault. */ type Options = { /** Resolves the onchain client used to read the vault. */ getClient: Viem.GetClient /** Chain and vault identity to resolve. */ input: unknown /** Private zones available to Earn routers. */ zones: readonly Chain[] } /** Options for resolving selected optional fields. */ type ProjectedOptions = Options & { /** Optional onchain fields to resolve. */ include: readonly Include[] } } /** Resolves and verifies every curated Zone route for one Earn vault. */ export async function resolveZoneRoutes( options: resolveZoneRoutes.Options, ): Promise { const result = z.array(schema.ZoneRoute).safeParse(options.routes) if (!result.success) throw invalid(result.error) const chainIds = new Set() const routers = new Set() for (const route of result.data) { if (chainIds.has(route.chainId)) throw new InvalidRegistryError(`Zone ${route.chainId} is configured more than once.`) if (routers.has(route.earnRouter)) throw new InvalidRegistryError( `Earn router ${route.earnRouter} is configured more than once.`, ) chainIds.add(route.chainId) routers.add(route.earnRouter) } if (result.data.length === 0) return [] const client = options.getClient(options.discovery.chainId) return Promise.all( result.data.map(async (route) => { const zone = options.zones.find((candidate) => candidate.id === route.chainId) if (!zone) throw new InvalidRegistryError(`Zone ${route.chainId} is not configured.`) if (zone.sourceId !== options.discovery.chainId) throw new InvalidRegistryError( `Zone ${route.chainId} does not belong to chain ${options.discovery.chainId}.`, ) const expectedZoneId = deriveZoneId(route.chainId) const [code, previousCode] = await Promise.all([ client.getCode({ address: route.earnRouter, blockNumber: BigInt(route.deploymentBlock), }), route.deploymentBlock === 0 ? undefined : client.getCode({ address: route.earnRouter, blockNumber: BigInt(route.deploymentBlock - 1), }), ]) if (!code || code === '0x' || (previousCode !== undefined && previousCode !== '0x')) throw mismatch({ discovery: options.discovery, issues: [ { actual: String(route.deploymentBlock), code: 'zone-deployment-block-mismatch', message: 'Earn router deployment block does not match its code boundary.', }, ], }) const bindings = await readZoneRouteBindings(client, { earnRouter: route.earnRouter }).catch( (error) => { if (!isMissingOptionalFunctionError(error)) throw error throw mismatch({ discovery: options.discovery, issues: [ { actual: route.earnRouter, code: 'zone-router-invalid', message: 'Earn router does not expose the required immutable bindings.', }, ], }) }, ) const issues: VerificationIssue[] = [] if (bindings.allowedZoneId !== expectedZoneId) issues.push({ actual: bindings.allowedZoneId.toString(), code: 'zone-id-mismatch', expected: String(expectedZoneId), message: 'Earn router is configured for a different Zone.', }) compareAddress( issues, 'zone-vault-mismatch', 'Earn router is configured for a different Earn vault.', options.discovery.vaultAddress, bindings.earnVault, ) compareAddress( issues, 'zone-vault-asset-mismatch', 'Earn router vault asset does not match the Earn vault.', options.discovery.assetToken.address, bindings.vaultAsset, ) compareAddress( issues, 'zone-share-mismatch', 'Earn router share token does not match the Earn vault.', options.discovery.shareToken.address, bindings.earnShare, ) if (!bindings.deposit || !bindings.redeem) issues.push({ actual: `deposit=${bindings.deposit},redeem=${bindings.redeem}`, code: 'zone-flow-unsupported', expected: 'deposit=true,redeem=true', message: 'Earn router must support private deposits and redemptions.', }) if (issues.length > 0) throw mismatch({ discovery: options.discovery, issues }) return { ...route, inputTokens: [bindings.privateAsset], name: zone.name, outputTokens: [bindings.privateAsset], } }), ) } export declare namespace resolveZoneRoutes { /** Inputs for resolving and verifying curated Zone routes. */ type Options = { /** Live state of the Earn vault bound by every router. */ discovery: Discovery /** Resolves the parent-chain client used to read each router. */ getClient: Viem.GetClient /** Curated Zone routes to verify. */ routes: unknown /** Private Zones configured by the API. */ zones: readonly Chain[] } } /** Infers the supported engine type from its capability fingerprint. */ export function classifyEngineType( options: classifyEngineType.Options, ): classifyEngineType.ReturnType { if (options.inKindDeposit && !options.asyncRedeem) return 'erc4626' if (options.asyncRedeem && !options.inKindDeposit) return 'veda' return null } export declare namespace classifyEngineType { /** Capability fingerprint used to classify an engine. */ type Options = { /** Whether queued redemptions are supported. */ asyncRedeem: boolean /** Whether venue-share deposits are supported. */ inKindDeposit: boolean } /** Supported engine type, or null for an unknown fingerprint. */ type ReturnType = 'erc4626' | 'veda' | null } async function resolveOnchain(client: Client, options: resolveOnchain.Options): Promise { const { include, input, zones } = options const includeAccess = include.includes('access') const includeCapabilities = include.includes('capabilities') const actualChainId = await getChainId(client) if (actualChainId === undefined) throw new VerificationError(unavailable()) if (actualChainId !== input.chainId) throw new VerificationError({ issues: [ { actual: String(actualChainId), code: 'chain-mismatch', expected: String(input.chainId), message: `Expected chain ${input.chainId}, received ${actualChainId}.`, }, ], status: 'unavailable', }) const vault = await readVaultBindings(client, input.vaultAddress).catch(async (error) => { if (isMissingContractError(error) && !(await client.getCode({ address: input.vaultAddress }))) throw new NotFoundError( `Earn vault ${input.vaultAddress} was not found on chain ${input.chainId}.`, ) throw error }) const engineType = classifyEngineType(vault.capabilities) const engineBindings = readEngineBindings(client, { engine: vault.engine }) const [access, asset, bindings, withdrawable, routerCapabilities, shareQuote, shareToken] = await Promise.all([ includeAccess ? readTransferPolicyId(client, vault.shareToken).then((transferPolicyId) => readAccess(client, transferPolicyId), ) : undefined, readTokenMetadata(client, vault.asset), engineBindings, // Instant liquidity is the venue's own withdrawal limit, so it waits on the // engine's venue binding. Only an ERC-4626 venue can expose one. engineBindings.then(({ venue }) => engineType === 'erc4626' && venue ? readVenueLiquidity(client, { owner: vault.engine, venue }) : undefined, ), includeCapabilities ? readRouterCapabilities(client, { chainId: input.chainId, zones }) : Promise.resolve({ deposit: false, redeem: false }), readShareQuote(client, { syncRedeem: vault.capabilities.syncRedeem, vaultAddress: input.vaultAddress, }), readTokenMetadata(client, vault.shareToken), ]) const { asset: engineAsset, venue } = bindings const assetToken = { address: vault.asset, currency: asset.currency, decimals: asset.decimals, name: asset.name, symbol: asset.symbol, } const shareToken_ = { address: vault.shareToken, currency: shareToken.currency, decimals: shareToken.decimals, name: shareToken.name, symbol: shareToken.symbol, } const issues: VerificationIssue[] = [] compareAddress( issues, 'engine-asset-mismatch', 'Engine asset does not match the vault asset.', vault.asset, engineAsset, ) if (issues.length > 0) throw new VerificationError({ issues, snapshot: { assetToken: parseTokenMetadata(assetToken), isSynced: vault.isSynced, shareToken: parseTokenMetadata(shareToken_), }, status: 'mismatch', }) const capabilities = (() => { if (!includeCapabilities) return undefined const privateRouting = routerCapabilities.deposit && routerCapabilities.redeem return { asyncRedeem: vault.capabilities.asyncRedeem, boundedRedeem: vault.capabilities.syncRedeem, deposit: true, exactWithdraw: vault.capabilities.exactWithdraw, inKindDeposit: vault.capabilities.inKindDeposit, privateRouting, redeem: vault.capabilities.syncRedeem, routerSwaps: privateRouting, } })() return parseDiscovery({ ...(access ? { access } : {}), assetToken, ...(capabilities ? { capabilities } : {}), chainId: input.chainId, engine: { address: vault.engine, type: engineType, venue, }, // The vault can never hand out more than its active backing, whatever the // venue would release. instantLiquidity: withdrawable === undefined ? null : (withdrawable < vault.totalAssets ? withdrawable : vault.totalAssets).toString(), sharePrice: shareQuote === undefined ? null : ((shareQuote * 10n ** BigInt(shareToken.decimals)) / quotedShares).toString(), shareToken: shareToken_, state: { depositsPaused: vault.depositsPaused, engineShares: vault.engineShares.toString(), feesActive: vault.feesActive, isAccountingAligned: vault.isSynced, openRedeemRequestCount: Number(vault.openRedeemRequestCount), totalAssets: vault.totalAssets.toString(), totalEarnShares: vault.shareSupply.toString(), }, vaultAddress: input.vaultAddress, }) } declare namespace resolveOnchain { type Options = { include: readonly resolve.Include[] input: ResolveInput zones: readonly Chain[] } } function parseInput(value: unknown): ResolveInput { const result = schema.ResolveInput.safeParse(value) if (!result.success) throw invalid(result.error) return result.data } function parseDiscovery(value: unknown): Discovery { const result = schema.Discovery.safeParse(value) if (!result.success) throw invalid(result.error) return result.data } function parseTokenMetadata( value: Pick, ): Discovery['assetToken'] { const result = schema.Discovery.shape.assetToken.safeParse(value) if (!result.success) throw invalid(result.error) return result.data } function invalid(error: z.core.$ZodError): InvalidRegistryError { const issue = error.issues[0] const path = issue?.path.length ? `${issue.path.join('.')}: ` : '' return new InvalidRegistryError(`${path}${issue?.message ?? 'invalid input'}`) } async function getChainId(client: Client): Promise { const existing = chainIds.get(client) if (existing) return existing const pending = client.getChainId().catch(() => { chainIds.delete(client) return undefined }) chainIds.set(client, pending) return pending } async function readAccess( client: Client, transferPolicyId: bigint | undefined, ): Promise> { if (transferPolicyId === 1n) return { status: 'open' } if (transferPolicyId === undefined || transferPolicyId <= 2n) throw new VerificationError({ issues: [ { actual: transferPolicyId?.toString(), code: 'policy-unsupported', message: 'Share token does not use an open or compound transfer policy.', }, ], snapshot: {}, status: 'mismatch', }) const flights = flightsFor(accessFlights, client) const existing = flights.get(transferPolicyId) if (existing) return existing const pending = (async () => { const [senderPolicyId, recipientPolicyId, mintRecipientPolicyId] = await client.readContract({ abi: Abis.tip403Registry, address: Addresses.tip403Registry, args: [transferPolicyId], functionName: 'compoundPolicyData', }) if ( senderPolicyId === 0n || recipientPolicyId === 0n || mintRecipientPolicyId === 0n || senderPolicyId !== 1n || recipientPolicyId !== mintRecipientPolicyId ) throw new VerificationError({ issues: [ { actual: transferPolicyId.toString(), code: 'policy-unsupported', message: 'Share token does not use a supported exit-safe compound policy.', }, ], snapshot: {}, status: 'mismatch', }) return { status: 'allowlisted' as const } })() flights.set(transferPolicyId, pending) try { return await pending } finally { flights.delete(transferPolicyId) } } async function readVaultBindings(client: Client, vaultAddress: Address): Promise { const vault = await client.earn.getVault({ vault: vaultAddress }) return { asset: vault.assetToken, capabilities: vault.capabilities, depositsPaused: vault.depositsPaused, engine: vault.engine.address, engineShares: vault.engineShares, feesActive: vault.feesActive, isSynced: vault.isSynced, openRedeemRequestCount: vault.pendingRedeemCount, shareSupply: vault.shareSupply, shareToken: vault.shareToken, totalAssets: vault.engine.totalAssets, } } /** * Assets the venue would let the engine withdraw right now, so an illiquid, * paused, or gated venue reports less than the engine's position is worth. There * is no fallback: valuing the position instead would restate `totalAssets` and * claim liquidity the venue never offered. */ function readVenueLiquidity( client: Client, options: readVenueLiquidity.Options, ): Promise { return client .readContract({ abi: venueLiquidityAbi, address: options.venue, args: [options.owner], functionName: 'maxWithdraw', }) .catch((error) => { // A venue that reports no withdrawal limit reports no instant liquidity. if (error instanceof ContractFunctionExecutionError) return undefined throw error }) } declare namespace readVenueLiquidity { type Options = { /** Account whose withdrawal limit is read, always the engine. */ owner: Address /** Venue the engine holds its position in. */ venue: Address } } /** * Assets returned for {@link quotedShares} Earn shares. The vault's * `previewRedeem` reverts unless the engine advertises synchronous redemption, * and the venue may refuse to value an exit at all. */ function readShareQuote( client: Client, options: readShareQuote.Options, ): Promise { if (!options.syncRedeem) return Promise.resolve(undefined) return client.earn .getRedeemQuote({ shareAmount: quotedShares, vault: options.vaultAddress }) .catch((error) => { if (error instanceof ContractFunctionExecutionError) return undefined throw error }) } declare namespace readShareQuote { type Options = { /** Whether the engine advertises synchronous redemption. */ syncRedeem: boolean /** Earn vault quoted against. */ vaultAddress: Address } } async function readEngineBindings(client: Client, options: readEngineBindings.Options) { const [asset, venue] = await Promise.all([ client.readContract({ abi: Abis.earnEngine, address: options.engine, functionName: 'asset', }), client .readContract({ abi: engineBindingsAbi, address: options.engine, functionName: 'vault', }) .catch((error) => { // Venue is concrete-engine metadata, not part of IEarnEngine. if (isMissingOptionalFunctionError(error)) return null throw error }), ]) return { asset, venue } } declare namespace readEngineBindings { type Options = { engine: Address } } async function readZoneRouteBindings( client: Client, options: readZoneRouteBindings.Options, ): Promise { const [allowedZoneId, earnShare, earnVault, privateAsset, vaultAsset, deposit, redeem] = await Promise.all([ client.readContract({ abi: Abis.earnRouter, address: options.earnRouter, functionName: 'allowedZoneId', }), client.readContract({ abi: Abis.earnRouter, address: options.earnRouter, functionName: 'earnShare', }), client.readContract({ abi: Abis.earnRouter, address: options.earnRouter, functionName: 'earnVault', }), client.readContract({ abi: Abis.earnRouter, address: options.earnRouter, functionName: 'privateAsset', }), client.readContract({ abi: Abis.earnRouter, address: options.earnRouter, functionName: 'vaultAsset', }), client.readContract({ abi: Abis.earnRouter, address: options.earnRouter, args: [0], functionName: 'supportsFlow', }), client.readContract({ abi: Abis.earnRouter, address: options.earnRouter, args: [1], functionName: 'supportsFlow', }), ]) return { allowedZoneId, deposit, earnShare, earnVault, privateAsset, redeem, vaultAsset } } declare namespace readZoneRouteBindings { type Options = { /** Parent-chain Earn router to inspect. */ earnRouter: Address } } async function readRouterCapabilities( client: Client, options: readRouterCapabilities.Options, ): Promise { const routers = new Map() for (const zone of options.zones) { if (zone.sourceId !== options.chainId) continue const router = zone.contracts?.['earnRouter'] if (router && 'address' in router) routers.set(router.address.toLowerCase(), router.address) } if (routers.size === 0) return { deposit: false, redeem: false } if (routers.size > 1) throw new InvalidRegistryError('Multiple Earn routers are configured for this chain.') const router = routers.values().next().value! const [deposit, redeem] = await Promise.all([ client.readContract({ abi: Abis.earnRouter, address: router, args: [0], functionName: 'supportsFlow', }), client.readContract({ abi: Abis.earnRouter, address: router, args: [1], functionName: 'supportsFlow', }), ]) return { deposit, redeem } } declare namespace readRouterCapabilities { type Options = { chainId: number zones: readonly Chain[] } } async function readTransferPolicyId(client: Client, address: Address): Promise { const flights = flightsFor(transferPolicyIdFlights, client) const existing = flights.get(address) if (existing) return existing const pending = client.readContract({ abi: Abis.tip20, address, functionName: 'transferPolicyId', }) flights.set(address, pending) try { return await pending } finally { flights.delete(address) } } async function readTokenMetadata(client: Client, address: Address): Promise { const flights = flightsFor(tokenMetadataFlights, client) const existing = flights.get(address) if (existing) return existing const pending = Promise.all([ client.readContract({ abi: Abis.tip20, address, functionName: 'currency' }), client.readContract({ abi: Abis.tip20, address, functionName: 'decimals' }), client.readContract({ abi: Abis.tip20, address, functionName: 'name' }), client.readContract({ abi: Abis.tip20, address, functionName: 'symbol' }), ]).then(([currency, decimals, name, symbol]) => ({ currency, decimals, name, symbol })) flights.set(address, pending) try { return await pending } finally { flights.delete(address) } } function isMissingContractError(error: unknown): boolean { return ( error instanceof ContractFunctionExecutionError && error.cause instanceof ContractFunctionZeroDataError ) } function isMissingOptionalFunctionError(error: unknown): boolean { if (!(error instanceof ContractFunctionExecutionError)) return false if (error.cause instanceof ContractFunctionZeroDataError) return true return ( error.cause instanceof ContractFunctionRevertedError && (!error.cause.raw || error.cause.raw === '0x') && (!error.cause.reason || error.cause.reason === 'execution reverted') ) } function flightsFor( registry: WeakMap>>, client: Client, ): Map> { const existing = registry.get(client) if (existing) return existing const flights = new Map>() registry.set(client, flights) return flights } function compareAddress( issues: VerificationIssue[], code: VerificationIssueCode, message: string, expected: Address, actual: Address, ): void { if (!isAddressEqual(actual, expected)) issues.push({ actual, code, expected, message }) } function mismatch(options: mismatch.Options): VerificationError { return new VerificationError({ issues: options.issues, snapshot: { assetToken: options.discovery.assetToken, isSynced: options.discovery.state.isAccountingAligned, shareToken: options.discovery.shareToken, }, status: 'mismatch', }) } declare namespace mismatch { type Options = { /** Live vault state captured with the mismatch. */ discovery: Discovery /** Verification issues that rejected the deployment. */ issues: readonly VerificationIssue[] } } function unavailable(): VerificationUnavailable { return { issues: [ { code: 'read-failed', message: 'Onchain verification reads are temporarily unavailable.', }, ], status: 'unavailable', } } /** Thrown when an Earn resolver value fails schema validation. */ export class InvalidRegistryError extends Error { override name = 'EarnVaults.InvalidRegistryError' } /** Thrown when a requested Earn vault does not exist. */ export class NotFoundError extends Error { override name = 'EarnVaults.NotFoundError' } /** Thrown when onchain verification rejects an Earn vault. */ export class VerificationError extends Error { /** Failed verification result. */ result: VerificationFailure constructor(result: VerificationFailure) { super(`Earn vault verification failed: ${result.issues.map((issue) => issue.code).join(', ')}.`) this.result = result } override name = 'EarnVaults.VerificationError' }