import { type Context, Hono } from 'hono' import * as z from 'zod/mini' import * as Earn from '../../apps/data/routes/earn.js' import * as Db from '../../db/Db.js' import * as core_EarnVaults from '../../db/tables/earnVaults.js' import * as RewardAccounts from '../../db/tables/rewardAccounts.js' import * as RewardCampaigns from '../../db/tables/rewardCampaigns.js' import * as RewardRuns from '../../db/tables/rewardRuns.js' import * as RewardTransactionAttempts from '../../db/tables/rewardTransactionAttempts.js' import * as Cursor from '../../internal/Cursor.js' import * as Campaigns from '../../internal/rewards/Campaigns.js' import * as RewardRead from '../../internal/rewards/Read.js' import * as EarnVaults from '../../internal/EarnVaults.js' import * as OpenApi from '../../internal/OpenApi.js' import * as Path from '../../internal/Path.js' import * as Response from '../../internal/Response.js' import * as Schema from '../../internal/Schema.js' import * as VerifiedTokens from '../../internal/VerifiedTokens.js' import * as Viem from '../../internal/Viem.js' import type * as App from '../App.js' /** Zod schemas owned by the admin verified-vault handler. */ export namespace schema { /** One Earn vault, shared with the public detail API. */ export const Vault = Earn.schema.Vault /** One registry-curated Earn vault, shared with the public verified list. */ export const VerifiedVault = Earn.schema.VerifiedVault /** Schemas for the listVerifiedEarnVaults operation. */ export namespace listVerifiedEarnVaults { /** Query parameters for verified Earn vaults, without the public rate window. */ export const Query = Earn.schema.getVerifiedEarnVaults.BaseQuery /** Paginated verified Earn vaults. */ export const Response = Earn.schema.getVerifiedEarnVaults.Response } /** Schemas for the getVerifiedEarnVault operation. */ export namespace getVerifiedEarnVault { /** Path parameters identifying one verified vault. */ export const Params = Earn.schema.getEarnVault.Params /** Query parameters selecting the vault chain and included fields. */ export const Query = Earn.schema.getEarnVault.BaseQuery /** One Earn vault resolved for registry management. */ export const Response = Vault } /** Schemas for the putVerifiedEarnVault operation. */ export namespace putVerifiedEarnVault { /** Curated fields used to verify and store one Earn vault. */ export const Body = z .strictObject({ chainId: Schema.ChainId, description: z .optional( z.nullable( z.string().check( z.describe('Curated vault description.'), z.meta({ examples: [ 'Bridge test PATHUSD deposited into the canonical synchronous Earn stack.', ], }), ), ), ) .check(z.describe('Curated vault description, or null when absent.')), label: z .optional( z .string() .check( z.minLength(1), z.describe('Curated vault label.'), z.meta({ examples: ['btPATHUSD Earn'] }), ), ) .check(z.describe('Curated vault label, defaulting to the share-token name.')), privateInputTokens: z .optional(z.array(Schema.Address)) .check(z.describe('Tokens accepted for private deposits.')), privateOutputTokens: z .optional(z.array(Schema.Address)) .check(z.describe('Tokens supported for private redemptions.')), slug: z .optional( z .string() .check( z.minLength(1), z.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), z.describe('Stable curated vault slug.'), z.meta({ examples: ['btpathusd-earn'] }), ), ) .check(z.describe('Custom vault slug, generated from the label when omitted.')), vaultAddress: Schema.Address.check( z.describe('Earn vault contract address.'), z.meta({ examples: ['0x4f94590b636f5878bce585e82379de81e1ec174f'] }), ), zones: z .optional(z.array(EarnVaults.schema.ZoneRoute)) .check(z.describe('Curated private Zone routes.')), }) .check(z.describe('Curated fields used to verify and store one Earn vault.')) /** The verified vault after the write. */ export const Response = VerifiedVault } /** Schemas for the deleteVerifiedEarnVault operation. */ export namespace deleteVerifiedEarnVault { /** Path parameters identifying one verified vault. */ export const Params = Earn.schema.getEarnVault.Params /** Query parameters selecting the vault chain. */ export const Query = z .strictObject({ chainId: Schema.ChainId }) .check(z.describe('Query parameters selecting one verified Earn vault.')) } /** Shared reward campaign response. */ export const RewardCampaign = z .strictObject({ chainId: Schema.ChainId, config: Campaigns.schema.Config, controllerAddress: z .nullable(Schema.Address) .check( z.describe('Target-yield controller address, or null before provisioning.'), z.meta({ examples: ['0x7000000000000000000000000000000000000007'] }), ), deliveredThrough: z .string() .check( z.regex(/^\d+$/), z.describe('Last fully delivered Unix interval boundary.'), z.meta({ examples: ['1788192000'] }), ), distributorAddress: z .nullable(Schema.Address) .check( z.describe('Merkle distributor address, or null before provisioning.'), z.meta({ examples: ['0x8000000000000000000000000000000000000008'] }), ), eventCursor: z .nullable( z .strictObject({ blockNumber: z .number() .check( z.int(), z.nonnegative(), z.describe('Last applied event block number.'), z.meta({ examples: [123456] }), ), logIndex: z .number() .check( z.int(), z.nonnegative(), z.describe('Last applied event log index.'), z.meta({ examples: [2] }), ), transactionIndex: z .number() .check( z.int(), z.nonnegative(), z.describe('Last applied event transaction index.'), z.meta({ examples: [1] }), ), }) .check(z.describe('Last applied TIDX event cursor.')), ) .check(z.describe('Last applied TIDX event cursor, or null before indexing.')), execution: z.nullable( z .strictObject({ boostRateBps: z.nullable( z .number() .check( z.int(), z.nonnegative(), z.describe('Measured boost APR in basis points.'), z.meta({ examples: [392] }), ), ), endsAt: z .string() .check( z.regex(/^\d+$/), z.describe('Exclusive execution end boundary.'), z.meta({ examples: ['1788192000'] }), ), error: z.nullable( z .string() .check( z.describe('Latest execution error.'), z.meta({ examples: ['Reward signer is unavailable.'] }), ), ), fundedAssets: z .nullable(Schema.DecimalString) .check( z.describe('Base assets funded during target-yield settlement.'), z.meta({ examples: ['1000000'] }), ), id: z .string() .check( z.describe('Durable reward-run identifier.'), z.meta({ examples: ['rrn_0123456789abcdefghijklmn'] }), ), intervalCount: z .number() .check( z.int(), z.nonnegative(), z.describe('Number of intervals covered by the run.'), z.meta({ examples: [1] }), ), liability: z .nullable(Schema.DecimalString) .check( z.describe('Cumulative EarnShare liability committed by the run.'), z.meta({ examples: ['500000'] }), ), mintedEarnShares: z .nullable(Schema.DecimalString) .check( z.describe('EarnShare minted while funding boost settlement.'), z.meta({ examples: ['500000'] }), ), organicRateBps: z.nullable( z .number() .check( z.int(), z.nonnegative(), z.describe('Latest contribution-adjusted interval APR in basis points.'), z.meta({ examples: [108] }), ), ), phase: z .enum([ 'indexing', 'calculating', 'targetYield', 'statement', 'settling', 'publishing', 'paying', 'delivered', 'failed', ]) .check( z.describe('Current durable execution phase.'), z.meta({ examples: ['delivered'] }), ), rootVersion: z .nullable(z.string().check(z.regex(/^\d+$/))) .check( z.describe('Confirmed distributor root version.'), z.meta({ examples: ['7'] }), ), startsAfter: z .string() .check( z.regex(/^\d+$/), z.describe('Previously delivered boundary where the run starts.'), z.meta({ examples: ['1788191700'] }), ), statementHash: z .nullable(Schema.Hash) .check( z.describe('Confirmed reward statement hash.'), z.meta({ examples: [`0x${'11'.repeat(32)}`] }), ), targetRateBps: z.nullable( z .number() .check( z.int(), z.nonnegative(), z.describe('Target total APR in basis points.'), z.meta({ examples: [500] }), ), ), transactions: z .array( z.strictObject({ expiresAt: z.nullable( z .string() .check( z.describe('Transaction expiry timestamp.'), z.meta({ examples: ['2026-08-31T12:00:00.000Z'] }), ), ), hash: z .nullable(Schema.Hash) .check( z.describe('Signed transaction hash.'), z.meta({ examples: [`0x${'22'.repeat(32)}`] }), ), operation: z .enum(['deploy', 'targetYield', 'settle', 'publish', 'push']) .check( z.describe('Reward operation encoded by the transaction.'), z.meta({ examples: ['push'] }), ), state: z .enum([ 'created', 'signed', 'broadcast', 'confirmed', 'expired', 'ineffective', 'reverted', ]) .check( z.describe('Durable transaction-attempt state.'), z.meta({ examples: ['confirmed'] }), ), updatedAt: z .string() .check( z.describe('Last transaction-attempt update (ISO 8601).'), z.meta({ examples: ['2026-08-31T12:00:00.000Z'] }), ), }), ) .check(z.describe('Transaction attempts belonging to the execution.')), updatedAt: z .string() .check( z.describe('Last execution update (ISO 8601).'), z.meta({ examples: ['2026-08-31T12:00:00.000Z'] }), ), }) .check(z.describe('Latest reward execution.')), ), paused: z .boolean() .check(z.describe('Whether new campaign work is paused.'), z.meta({ examples: [false] })), pendingConfig: z .nullable(Campaigns.schema.Config) .check(z.describe('Complete configuration scheduled for a future boundary.')), pendingEffectiveAt: z .nullable(z.string().check(z.regex(/^\d+$/))) .check( z.describe('Unix boundary when pending configuration becomes active.'), z.meta({ examples: ['1788192300'] }), ), peripheryReady: z .boolean() .check( z.describe('Whether required reward periphery is provisioned.'), z.meta({ examples: [true] }), ), provisioningError: z .nullable(z.string()) .check( z.describe('Latest reward-periphery provisioning error.'), z.meta({ examples: ['Reward signer is unavailable.'] }), ), signerAddress: z .nullable(Schema.Address) .check( z.describe('Signer permanently bound to the reward periphery.'), z.meta({ examples: ['0x9000000000000000000000000000000000000009'] }), ), unpaidEarnShares: Schema.DecimalString.check( z.describe('Current unpaid EarnShare across all recipients.'), z.meta({ examples: ['0'] }), ), unpaidRecipients: z .number() .check( z.int(), z.nonnegative(), z.describe('Recipients with a positive unpaid balance.'), z.meta({ examples: [0] }), ), vaultAddress: Schema.Address.check(z.describe('EarnVault address identifying the campaign.')), }) .check(z.describe('Current reward campaign configuration and execution state.')) /** Schemas for reward campaign administration. */ export namespace rewardCampaign { /** Complete campaign configuration. */ export const Body = Campaigns.schema.Config.check( z.describe('Complete reward configuration for one verified Earn vault.'), ) /** Path parameters identifying one verified vault. */ export const Params = Earn.schema.getEarnVault.Params /** Query parameters selecting the vault chain. */ export const Query = z .strictObject({ chainId: Schema.ChainId }) .check(z.describe('Query parameters selecting one verified Earn vault.')) /** Current campaign configuration and readiness. */ export const Response = RewardCampaign } } /** Admin routes for reading and managing verified Earn vaults. */ export function earnVaults() { return new Hono() .get( '/', OpenApi.validate('query', schema.listVerifiedEarnVaults.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'listVerifiedEarnVaults', responses: OpenApi.responses({ success: { description: 'A page of verified Earn vaults.', schema: schema.listVerifiedEarnVaults.Response, }, }), summary: 'List verified vaults', tags: ['Earn Vaults'], }), async (c) => { const query = c.req.valid('query') try { const chainId = query.chainId ?? Viem.defaultChainId const data = await listVerifiedVaults(c, { ...query, chainId }) return c.json(Response.validated(schema.listVerifiedEarnVaults.Response, data), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/:vaultId', OpenApi.validate('param', schema.getVerifiedEarnVault.Params, { code: 'vault_id_invalid', message: 'Invalid vault id', }), OpenApi.validate('query', schema.getVerifiedEarnVault.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'getVerifiedEarnVault', responses: OpenApi.responses({ errors: { 404: { codes: ['earn_vault_not_found'], description: 'The verified vault was not found.', }, }, success: { description: 'One verified Earn vault.', schema: schema.getVerifiedEarnVault.Response, }, }), summary: 'Get verified vault', tags: ['Earn Vaults'], }), async (c) => { const { vaultId } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? Viem.defaultChainId try { const data = await getVault(c, { chainId, include: query.include, vaultAddress: vaultId, }) return c.json(Response.validated(schema.getVerifiedEarnVault.Response, data), 200) } catch (cause) { if (cause instanceof Earn.NotFoundError || Earn.isIncompatible(cause)) return notFound(c) return Response.upstream(c, cause) } }, ) .get( '/:vaultId/rewards', OpenApi.validate('param', schema.rewardCampaign.Params, { code: 'vault_id_invalid', message: 'Invalid vault id', }), OpenApi.validate('query', schema.rewardCampaign.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'getEarnRewardCampaign', responses: OpenApi.responses({ errors: { 404: { codes: ['earn_reward_campaign_not_found'], description: 'The reward campaign was not found.', }, }, success: { description: 'The configured reward campaign.', schema: schema.rewardCampaign.Response, }, }), summary: 'Get reward campaign', tags: ['Earn Vaults'], }), async (c) => { const { vaultId } = c.req.valid('param') const { chainId } = c.req.valid('query') const db = Db.get(c.get('db')) const campaign = await RewardCampaigns.get(db, { chainId, vaultAddress: vaultId, }) if (!campaign) return Response.error(c, { code: 'earn_reward_campaign_not_found', message: 'Reward campaign not found', status: 404, }) let unpaid: RewardAccounts.unpaid.Result try { unpaid = await RewardRead.unpaid({ campaign, db, getClient: c.get('getClient'), }) } catch (cause) { return Response.upstream(c, cause) } return c.json( Response.validated( schema.rewardCampaign.Response, await serializeCampaign(db, { campaign, unpaid, }), ), 200, ) }, ) .post( '/:vaultId/rewards', OpenApi.validate('param', schema.rewardCampaign.Params, { code: 'vault_id_invalid', message: 'Invalid vault id', }), OpenApi.validate('query', schema.rewardCampaign.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.validate('json', schema.rewardCampaign.Body, { code: 'body_invalid', message: 'Invalid reward configuration', }), OpenApi.describeRoute({ operationId: 'postEarnRewardCampaign', responses: OpenApi.responses({ errors: { 404: { codes: ['earn_vault_not_found'], description: 'The verified vault was not found.', }, 409: { codes: ['earn_reward_campaign_invalid'], description: 'The configuration cannot replace the active campaign.', }, }, success: { description: 'The configured reward campaign.', schema: schema.rewardCampaign.Response, }, }), summary: 'Configure reward campaign', tags: ['Earn Vaults'], }), async (c) => { const { vaultId } = c.req.valid('param') const { chainId } = c.req.valid('query') const config = c.req.valid('json') if (chainId !== Viem.chainId.mainnet) return Response.error(c, { code: 'earn_reward_campaign_invalid', message: 'Reward campaigns are currently supported only on Tempo mainnet.', status: 409, }) const db = Db.get(c.get('db')) const verified = await core_EarnVaults.get(db, { chainId, vaultAddress: vaultId }) if (!verified) return notFound(c) const now = Math.floor(Date.now() / 1_000) const existing = await RewardCampaigns.get(db, { chainId, vaultAddress: vaultId }) const startsAt = Math.min( config.targetYield?.startTimestamp ?? Number.MAX_SAFE_INTEGER, config.boostRewards?.startTimestamp ?? Number.MAX_SAFE_INTEGER, ) if (!existing && startsAt <= now) return Response.error(c, { code: 'earn_reward_campaign_invalid', message: 'A new reward campaign must start on a future interval boundary.', status: 409, }) try { const discovery = await EarnVaults.resolve({ getClient: c.get('getClient'), input: { chainId, vaultAddress: vaultId }, zones: c.get('zones'), }) if (discovery.assetToken.currency !== 'USD') return Response.error(c, { code: 'earn_reward_campaign_invalid', message: 'Reward campaigns currently require a USD-denominated base asset.', status: 409, }) const campaign = await RewardCampaigns.upsert(db, { assetAddress: discovery.assetToken.address, assetDecimals: discovery.assetToken.decimals, chainId, config, earnShareAddress: discovery.shareToken.address, earnShareDecimals: discovery.shareToken.decimals, now, vaultAddress: discovery.vaultAddress, }) const unpaid = await RewardAccounts.unpaid(db, campaign) return c.json( Response.validated( schema.rewardCampaign.Response, await serializeCampaign(db, { campaign, unpaid, }), ), 200, ) } catch (cause) { if (cause instanceof RewardCampaigns.ConfigurationError) return Response.error(c, { code: 'earn_reward_campaign_invalid', message: cause.message, status: 409, }) return Response.upstream(c, cause) } }, ) .post( '/:vaultId/rewards/pause', OpenApi.validate('param', schema.rewardCampaign.Params, { code: 'vault_id_invalid', message: 'Invalid vault id', }), OpenApi.validate('query', schema.rewardCampaign.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'pauseEarnRewardCampaign', responses: OpenApi.responses({ errors: { 404: { codes: ['earn_reward_campaign_not_found'], description: 'The reward campaign was not found.', }, }, success: { description: 'The paused reward campaign.', schema: schema.rewardCampaign.Response, }, }), summary: 'Pause reward campaign', tags: ['Earn Vaults'], }), async (c) => updateCampaignPause(c, { chainId: c.req.valid('query').chainId, paused: true, vaultAddress: c.req.valid('param').vaultId, }), ) .post( '/:vaultId/rewards/resume', OpenApi.validate('param', schema.rewardCampaign.Params, { code: 'vault_id_invalid', message: 'Invalid vault id', }), OpenApi.validate('query', schema.rewardCampaign.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'resumeEarnRewardCampaign', responses: OpenApi.responses({ errors: { 404: { codes: ['earn_reward_campaign_not_found'], description: 'The reward campaign was not found.', }, }, success: { description: 'The resumed reward campaign.', schema: schema.rewardCampaign.Response, }, }), summary: 'Resume reward campaign', tags: ['Earn Vaults'], }), async (c) => updateCampaignPause(c, { chainId: c.req.valid('query').chainId, paused: false, vaultAddress: c.req.valid('param').vaultId, }), ) .put( '/', OpenApi.validate('json', schema.putVerifiedEarnVault.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ operationId: 'putVerifiedEarnVault', responses: OpenApi.responses({ errors: { 404: { codes: ['earn_vault_not_found'], description: 'The vault was not found on the selected chain.', }, 409: { codes: ['earn_vault_verification_failed'], description: 'The contracts do not form a supported Earn vault.', }, }, success: { description: 'The verified Earn vault.', schema: schema.putVerifiedEarnVault.Response, }, }), summary: 'Put verified vault', tags: ['Earn Vaults'], }), async (c) => { const input = c.req.valid('json') const db = Db.get(c.get('db')) const existing = await core_EarnVaults.get(db, { chainId: input.chainId, vaultAddress: input.vaultAddress, }) try { const discovery = await EarnVaults.resolve({ getClient: c.get('getClient'), input: { chainId: input.chainId, vaultAddress: input.vaultAddress, }, zones: c.get('zones'), }) if (!discovery.engine.type) return verificationFailed(c, 'The vault uses an unsupported engine type.') const privateInputTokens = input.privateInputTokens ?? existing?.privateInputTokens ?? [] const privateOutputTokens = input.privateOutputTokens ?? existing?.privateOutputTokens ?? [] const zoneRoutes = input.zones ?? existing?.zones ?? [] const hasPrivateInputs = privateInputTokens.length > 0 const hasPrivateOutputs = privateOutputTokens.length > 0 if (hasPrivateInputs !== hasPrivateOutputs) return verificationFailed(c, 'Private input and output tokens must both be configured.') const resolvedZoneRoutes = await EarnVaults.resolveZoneRoutes({ discovery, getClient: c.get('getClient'), routes: zoneRoutes, zones: c.get('zones'), }) const capabilities = resolvedZoneRoutes.length > 0 ? EarnVaults.addZoneRouteCapabilities(discovery.capabilities) : discovery.capabilities if (hasPrivateInputs && !capabilities.privateRouting) return verificationFailed(c, 'The chain does not support private Earn routing.') await core_EarnVaults.upsert(db, { chainId: input.chainId, description: input.description === undefined ? (existing?.description ?? null) : input.description, label: input.label ?? existing?.label ?? discovery.shareToken.name, privateInputTokens, privateOutputTokens, ...(input.slug !== undefined ? { slug: input.slug } : {}), vaultAddress: input.vaultAddress, zones: zoneRoutes, }) const data = await getVault(c, { chainId: input.chainId, include: ['access', 'capabilities', 'zone', 'zones'], vaultAddress: input.vaultAddress, }) return c.json(Response.validated(schema.putVerifiedEarnVault.Response, data), 200) } catch (cause) { return mutationError(c, cause) } }, ) .delete( '/:vaultId', OpenApi.validate('param', schema.deleteVerifiedEarnVault.Params, { code: 'vault_id_invalid', message: 'Invalid vault id', }), OpenApi.validate('query', schema.deleteVerifiedEarnVault.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'deleteVerifiedEarnVault', responses: { 204: { description: 'The vault was removed from the verified registry.' }, 400: OpenApi.standardError(400, 'Invalid request.', [ 'query_invalid', 'vault_id_invalid', ]), 401: OpenApi.standardError(401, 'Admin authentication required.'), 404: OpenApi.standardError(404, 'Verified Earn vault not found.', [ 'earn_vault_not_found', ]), 409: OpenApi.standardError(409, 'The vault has retained reward campaign history.', [ 'earn_vault_rewards_exist', ]), 429: OpenApi.standardError(429, 'Rate limited.'), 500: OpenApi.standardError(500, 'Internal server error.'), }, summary: 'Remove verified vault', tags: ['Earn Vaults'], }), async (c) => { const { vaultId } = c.req.valid('param') const { chainId } = c.req.valid('query') try { const record = await core_EarnVaults.remove(Db.get(c.get('db')), { chainId, vaultAddress: vaultId, }) if (!record) return notFound(c) return c.body(null, 204) } catch (cause) { if (isRewardCampaignConstraint(cause)) return Response.error(c, { code: 'earn_vault_rewards_exist', message: 'A vault with reward campaign history cannot be removed because recovery claims remain available.', status: 409, }) throw cause } }, ) } async function serializeCampaign( db: Db.Db, options: serializeCampaign.Options, ): Promise> { const { campaign, unpaid } = options const execution = await RewardRuns.latestSummary(db, campaign) const attempts = execution ? await RewardTransactionAttempts.listForRun(db, execution.id) : [] return { chainId: campaign.chainId, config: campaign.config, controllerAddress: campaign.controllerAddress, deliveredThrough: campaign.deliveredThrough, distributorAddress: campaign.distributorAddress, eventCursor: campaign.eventCursor, execution: execution ? { boostRateBps: execution.evidence?.boostRateBps ?? null, endsAt: execution.endsAt, error: execution.error, fundedAssets: execution.fundedAssets, id: execution.id, intervalCount: execution.evidence?.intervals.length ?? 0, liability: execution.liability, mintedEarnShares: execution.mintedEarnShares, organicRateBps: execution.evidence?.organicRateBps ?? null, phase: execution.phase, rootVersion: execution.rootVersion, startsAfter: execution.startsAfter, statementHash: execution.statementHash, targetRateBps: execution.evidence?.targetRateBps ?? null, transactions: attempts.map((attempt) => ({ expiresAt: attempt.expiresAt, hash: attempt.transactionHash, operation: attempt.intent.operation, state: attempt.state, updatedAt: attempt.updatedAt, })), updatedAt: execution.updatedAt, } : null, paused: campaign.paused, pendingConfig: campaign.pendingConfig, pendingEffectiveAt: campaign.pendingEffectiveAt, provisioningError: campaign.provisioningError, signerAddress: campaign.signerAddress, peripheryReady: !campaign.provisioningError && [campaign.config, campaign.pendingConfig].every( (config) => !config || ((!config.targetYield || campaign.controllerAddress !== null) && (!config.boostRewards || campaign.distributorAddress !== null)), ), unpaidEarnShares: unpaid.earnShares, unpaidRecipients: unpaid.recipients, vaultAddress: campaign.vaultAddress, } } declare namespace serializeCampaign { /** Campaign serialization dependencies. */ type Options = { /** Stored reward campaign. */ campaign: RewardCampaigns.Record /** Unpaid totals selected by the caller's consistency boundary. */ unpaid: RewardAccounts.unpaid.Result } } async function updateCampaignPause( c: Context, options: updateCampaignPause.Options, ) { const db = Db.get(c.get('db')) const campaign = await RewardCampaigns.setPaused(db, { chainId: options.chainId, paused: options.paused, vaultAddress: options.vaultAddress, }) if (!campaign) return Response.error(c, { code: 'earn_reward_campaign_not_found', message: 'Reward campaign not found', status: 404, }) let unpaid: RewardAccounts.unpaid.Result try { unpaid = await RewardRead.unpaid({ campaign, db, getClient: c.get('getClient'), }) } catch { // Pausing is an operational safety action and must remain available during RPC incidents. // The next healthy read or scheduler pass reconciles permissionless claims. unpaid = await RewardAccounts.unpaid(db, campaign) } return c.json( Response.validated( schema.rewardCampaign.Response, await serializeCampaign(db, { campaign, unpaid }), ), 200, ) } declare namespace updateCampaignPause { /** Validated campaign pause mutation. */ type Options = { /** Chain containing the campaign. */ chainId: number /** Replacement pause state. */ paused: boolean /** EarnVault identifying the campaign. */ vaultAddress: string } } async function listVerifiedVaults( c: Context, options: listVerifiedVaults.Options, ) { const db = Db.get(c.get('db')) const [records, snapshot] = await Promise.all([ core_EarnVaults.list(db, { chainId: options.chainId }), VerifiedTokens.read(db, 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 matches: listVerifiedVaults.Match[] = [] for (const record of candidates) { const discovered = await resolveVault(c, { chainId: options.chainId, include: discoveryInclude(options), vaultAddress: record.vaultAddress, }) const { discovery, zoneRoutes } = await resolveVaultRoutes(c, { capability: options.capability, discovery: discovered, include: options.include, record, }) if (!Earn.matchesVaultFilters(discovery, options)) continue matches.push({ discovery, record, zoneRoutes }) if (matches.length > options.limit) break } const page = matches.slice(0, options.limit) const data = await Promise.all( page.map((match) => serializeVault(c, { ...match, chainId: options.chainId, include: options.include, snapshot, }), ), ) const last = matches[options.limit - 1] return { data, nextCursor: matches.length > options.limit && last ? Cursor.encode([last.record.vaultAddress]) : null, } } declare namespace listVerifiedVaults { type Match = { /** Live onchain vault state. */ discovery: EarnVaults.Discovery /** Curated registry row. */ record: core_EarnVaults.Record /** Verified private Zone routes selected for serialization. */ zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } type Options = z.output & { /** Parent chain containing the vaults. */ chainId: z.output } } async function getVault(c: Context, options: getVault.Options) { const db = Db.get(c.get('db')) const record = await core_EarnVaults.get(db, options) try { const [discovered, snapshot] = await Promise.all([ resolveVault(c, { ...options, include: discoveryInclude({ capability: [], include: options.include }), }), VerifiedTokens.read(db, options.chainId), ]) const { discovery, zoneRoutes } = await resolveVaultRoutes(c, { capability: [], discovery: discovered, include: options.include, record, }) return serializeVault(c, { ...options, discovery, record, snapshot, zoneRoutes }) } catch (cause) { if (Earn.isIncompatible(cause)) throw new Earn.NotFoundError() throw cause } } declare namespace getVault { type Options = { /** Parent chain containing the vault. */ chainId: z.output /** Optional embedded token fields. */ include: z.output['include'] /** Earn vault address. */ vaultAddress: z.output } } function resolveVault(c: Context, options: resolveVault.Options) { return EarnVaults.resolve({ getClient: c.get('getClient'), include: options.include, input: { chainId: options.chainId, vaultAddress: options.vaultAddress, }, zones: c.get('zones'), }) } declare namespace resolveVault { type Options = { /** Parent chain containing the vault. */ chainId: z.output /** Onchain vault fields required by the request. */ include: readonly EarnVaults.resolve.Include[] /** Earn vault address. */ 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 includesRoutes = options.include.includes('zone') || options.include.includes('zones') const needsCapabilities = options.include.includes('capabilities') || options.capability.includes('privateRouting') || options.capability.includes('routerSwaps') if (!includesRoutes && !needsCapabilities) return { discovery, zoneRoutes: [] } const zoneRoutes = await EarnVaults.resolveZoneRoutes({ discovery, getClient: c.get('getClient'), routes: record.zones, zones: c.get('zones'), }) 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 ? zoneRoutes : [], } } declare namespace resolveVaultRoutes { type Options = { capability: z.output['capability'] discovery: EarnVaults.Discovery include: z.output['include'] record?: core_EarnVaults.Record | undefined } type Result = { discovery: EarnVaults.Discovery zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } } async function serializeVault( c: Context, options: serializeVault.Options, ): Promise> { const [assetToken, shareToken] = await Promise.all([ serializeToken(c, { ...options, metadata: options.discovery.assetToken }), serializeToken(c, { ...options, metadata: options.discovery.shareToken }), ]) return Earn.serializeVault({ // Rate and valuation enrichment need indexer and FX access the admin app // does not carry, so its query schema cannot select either field. apy: null, assetToken, discovery: options.discovery, include: options.include, instantLiquidityValue: null, record: options.record, shareToken, tvl: null, zoneRoutes: options.zoneRoutes, zones: c.get('zones'), }) } declare namespace serializeVault { type Options = { /** Parent chain containing the vault. */ chainId: z.output /** Live onchain vault state. */ discovery: EarnVaults.Discovery /** Optional embedded token fields. */ include: z.output['include'] /** Curated registry row, when verified. */ record?: core_EarnVaults.Record | undefined /** Verified-token snapshot, when configured. */ snapshot: VerifiedTokens.Snapshot | null /** Verified private Zone routes selected for serialization. */ zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } } 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< z.output, 'capability' | 'include' > } async function serializeToken( c: Context, options: serializeToken.Options, ): Promise> { const address = Schema.Address.parse(options.metadata.address.toLowerCase()) const logoUri = options.include.includes('token.logoUri') ? await resolveLogoUri(c, { address, chainId: options.chainId }) : undefined return Response.validated(Earn.schema.Vault.shape.assetToken, { address, currency: options.metadata.currency, decimals: options.metadata.decimals, id: address, logoUri, name: options.metadata.name, symbol: options.metadata.symbol, verified: options.snapshot?.byAddress.has(address) ?? false, }) } declare namespace serializeToken { type Options = { /** Parent chain containing the token. */ chainId: z.output /** Optional embedded token fields. */ include: z.output['include'] /** Onchain token metadata. */ metadata: EarnVaults.Discovery['assetToken'] /** Verified-token snapshot, when configured. */ snapshot: VerifiedTokens.Snapshot | null } } async function resolveLogoUri( c: Context, options: resolveLogoUri.Options, ): Promise { const asset = await c.get('assets')?.get(`${options.chainId}/icons/${options.address}`) if (!asset) return undefined const uri = new URL( Path.join(c.get('basePath'), 'verified-tokens', options.address, 'logo'), c.req.url, ) uri.searchParams.set('chainId', String(options.chainId)) return uri.toString() } declare namespace resolveLogoUri { type Options = { /** Token address. */ address: z.output /** Parent chain containing the token. */ chainId: z.output } } function mutationError(c: Context, cause: unknown) { if (cause instanceof EarnVaults.InvalidRegistryError) return Response.error(c, { code: 'body_invalid', details: [{ message: cause.message }], message: 'Invalid verified Earn vault', status: 400, }) if (cause instanceof EarnVaults.VerificationError && cause.result.status === 'mismatch') return Response.error(c, { code: 'earn_vault_verification_failed', details: cause.result.issues.map((issue) => ({ message: issue.message })), message: 'Earn vault verification failed', status: 409, }) if (cause instanceof Earn.NotFoundError) return notFound(c) if (cause instanceof EarnVaults.NotFoundError) return notFound(c, cause.message) return Response.upstream(c, cause) } function isRewardCampaignConstraint( cause: unknown, ): cause is { code: '23503'; constraint: 'reward_campaigns_vault_fkey' } { return ( cause instanceof Error && 'code' in cause && cause.code === '23503' && 'constraint' in cause && cause.constraint === 'reward_campaigns_vault_fkey' ) } function notFound(c: Context, message = 'Verified Earn vault not found') { return Response.error(c, { code: 'earn_vault_not_found', message, status: 404, }) } function verificationFailed(c: Context, message: string) { return Response.error(c, { code: 'earn_vault_verification_failed', details: [{ message }], message: 'Earn vault verification failed', status: 409, }) }