import * as fs from 'node:fs' import { Cli as incur_Cli, z } from 'incur' import * as zod from 'zod/mini' import * as Analytics from '../../analytics/Analytics.js' import * as Db from '../../db/Db.js' import * as RewardEligibilityAssociations from '../../db/tables/rewardEligibilityAssociations.js' import * as Audit from '../../internal/rewards/Audit.js' import * as Credits from '../../internal/rewards/Credits.js' import * as Schema from '../../internal/Schema.js' import * as Tidx from '../../internal/Tidx.js' /** * Applies pending Postgres migrations, connecting directly via * `--database-url` or `DATABASE_URL` (never through Hyperdrive — the migrate * advisory lock needs session semantics); `--db-schema` scopes the session. * When ClickHouse migrate credentials are configured, also applies analytics * schema migrations ({@link Analytics.migrations}) over the HTTP interface. */ const migrate = incur_Cli.create('migrate', { description: 'Apply pending database migrations.', env: z.object({ CLICKHOUSE_DATABASE: z .string() .optional() .describe('ClickHouse database for analytics migrations.'), CLICKHOUSE_MIGRATE_PASSWORD: z .string() .optional() .describe('Password for the DDL-capable ClickHouse user.'), CLICKHOUSE_MIGRATE_USER: z .string() .optional() .describe('DDL-capable ClickHouse user for analytics migrations.'), CLICKHOUSE_URL: z.string().optional().describe('ClickHouse HTTPS endpoint.'), DATABASE_URL: z .string() .optional() .describe('Postgres connection string. Used when --database-url is omitted.'), }), options: z.object({ clickhouseDatabase: z .string() .optional() .describe('ClickHouse database. Overrides CLICKHOUSE_DATABASE.'), clickhouseMigratePassword: z .string() .optional() .describe('ClickHouse DDL password. Overrides CLICKHOUSE_MIGRATE_PASSWORD.'), clickhouseMigrateUser: z .string() .optional() .describe('ClickHouse DDL user. Overrides CLICKHOUSE_MIGRATE_USER.'), clickhouseUrl: z .string() .optional() .describe('ClickHouse HTTPS endpoint. Overrides CLICKHOUSE_URL.'), databaseUrl: z .string() .optional() .describe('Postgres connection string. Overrides DATABASE_URL.'), dbSchema: z.string().optional().describe('Postgres schema to create and scope the session to.'), }), async run(c) { const connectionString = c.options.databaseUrl ?? c.env.DATABASE_URL if (!connectionString) return c.error({ code: 'missing_database_url', message: 'Set --database-url or DATABASE_URL to the Postgres connection string.', }) const analytics = clickhouseConfig({ database: c.options.clickhouseDatabase ?? c.env.CLICKHOUSE_DATABASE, password: c.options.clickhouseMigratePassword ?? c.env.CLICKHOUSE_MIGRATE_PASSWORD, url: c.options.clickhouseUrl ?? c.env.CLICKHOUSE_URL, user: c.options.clickhouseMigrateUser ?? c.env.CLICKHOUSE_MIGRATE_USER, }) if (analytics?.missing) return c.error({ code: 'missing_clickhouse_config', message: `ClickHouse analytics migration is partially configured; set ${analytics.missing.join(', ')}.`, }) const db = Db.postgres({ connectionString, ...(c.options.dbSchema ? { schema: c.options.dbSchema } : {}), }) try { await db.migrate() } catch (error) { /* v8 ignore next */ const message = error instanceof Error ? error.message : 'Migration failed.' return c.error({ code: 'migrate_failed', message, }) } finally { await db.close() } if (!analytics) return { migrated: true } try { await Analytics.clickhouse(analytics.options).migrate() } catch (error) { /* v8 ignore next */ const message = error instanceof Error ? error.message : 'Migration failed.' return c.error({ code: 'migrate_failed', message, }) } return { analytics: true, migrated: true } }, }) const rewardEligibilityImport = zod.array( zod.strictObject({ chainId: Schema.ChainId, transactionHash: zod.optional(Schema.Hash), vaultAddress: Schema.Address, walletAddress: Schema.Address, }), ) /** Reviews or applies an import of existing reward eligibility associations. */ const importRewardEligibility = incur_Cli.create('import-reward-eligibility', { description: 'Review or import existing reward eligibility associations.', env: z.object({ DATABASE_URL: z .string() .optional() .describe('Postgres connection string. Used when --database-url is omitted.'), }), options: z.object({ apply: z.boolean().default(false).describe('Persist the reviewed import. Defaults to dry-run.'), databaseUrl: z .string() .optional() .describe('Postgres connection string. Overrides DATABASE_URL.'), dbSchema: z.string().optional().describe('Postgres schema containing the API tables.'), file: z.string().describe('JSON file containing reward eligibility associations.'), }), async run(c) { const parsed = rewardEligibilityImport.safeParse( (() => { try { return JSON.parse(fs.readFileSync(c.options.file, 'utf8')) } catch { return undefined } })(), ) if (!parsed.success) return c.error({ code: 'invalid_reward_eligibility_import', message: 'The import file must contain valid reward eligibility associations.', }) const associations = parsed.data if (!c.options.apply) return { applied: false, associations } const connectionString = c.options.databaseUrl ?? c.env.DATABASE_URL if (!connectionString) return c.error({ code: 'missing_database_url', message: 'Set --database-url or DATABASE_URL to apply the import.', }) const db = c.options.dbSchema ? Db.postgres({ connectionString, schema: c.options.dbSchema }) : Db.postgres({ connectionString }) try { const results = await db.transaction(async (tx) => { const results: RewardEligibilityAssociations.upsert.Result[] = [] for (const association of associations) results.push(await RewardEligibilityAssociations.upsert(tx, association)) return results }) return { applied: true, created: results.filter((result) => result.created).length, replayed: results.filter((result) => !result.created).length, } } catch (error) { /* v8 ignore next */ const message = error instanceof Error ? error.message : 'Import failed.' return c.error({ code: 'reward_eligibility_import_failed', message }) } finally { await db.close() } }, }) /** * Compares stored Earn reward projections with a fresh TIDX replay and, with * `--apply`, resets under-allocated accounts so the next run rebuilds them. */ const auditRewardAccounts = incur_Cli.create('audit-reward-accounts', { description: 'Review or repair Earn reward accounts that missed deposits.', env: z.object({ DATABASE_URL: z .string() .optional() .describe('Postgres connection string. Used when --database-url is omitted.'), TIDX_AUTH: z.string().optional().describe('TIDX credential. Used when --tidx-auth is omitted.'), TIDX_URL: z.string().optional().describe('TIDX base URL. Used when --tidx-url is omitted.'), }), options: z.object({ apply: z .boolean() .default(false) .describe('Reset accounts with missing deposits for replay. Defaults to dry-run.'), chainId: z.number().int().default(4217).describe('Chain containing the vault.'), databaseUrl: z .string() .optional() .describe('Postgres connection string. Overrides DATABASE_URL.'), dbSchema: z.string().optional().describe('Postgres schema containing the API tables.'), tidxAuth: z.string().optional().describe('TIDX credential. Overrides TIDX_AUTH.'), tidxUrl: z.string().optional().describe('TIDX base URL. Overrides TIDX_URL.'), vaultAddress: z.string().describe('EarnVault address of the campaign.'), }), async run(c) { const connectionString = c.options.databaseUrl ?? c.env.DATABASE_URL if (!connectionString) return c.error({ code: 'missing_database_url', message: 'Set --database-url or DATABASE_URL to the Postgres connection string.', }) const vaultAddress = Schema.Address.safeParse(c.options.vaultAddress) if (!vaultAddress.success) return c.error({ code: 'invalid_vault_address', message: 'The vault address must be a 20-byte hex address.', }) const baseUrl = c.options.tidxUrl ?? c.env.TIDX_URL const auth = c.options.tidxAuth ?? c.env.TIDX_AUTH const tidx = Tidx.getClient({ chainId: c.options.chainId, tidx: { ...(auth ? { auth } : {}), ...(baseUrl ? { baseUrl } : {}) }, }) const db = c.options.dbSchema ? Db.postgres({ connectionString, schema: c.options.dbSchema }) : Db.postgres({ connectionString }) try { const report = await Audit.audit({ chainId: c.options.chainId, db, tidx, vaultAddress: vaultAddress.data, }) if (!c.options.apply) return { applied: false, ...report } // Only the allocation-shortfall accounts are reset; other drift stays for manual review. const reset = await Audit.reset({ chainId: c.options.chainId, db, recipients: report.missingDeposits.map((entry) => entry.recipient), vaultAddress: vaultAddress.data, }) return { applied: true, reset, ...report } } catch (error) { if (error instanceof Audit.AuditError) return c.error({ code: 'reward_audit_unavailable', message: error.message }) /* v8 ignore next */ const message = error instanceof Error ? error.message : 'Audit failed.' return c.error({ code: 'reward_audit_failed', message }) } finally { await db.close() } }, }) const rewardCreditBatch = zod.array( zod.strictObject({ assets: zod.string().check(zod.regex(/^[1-9]\d*$/)), recipient: Schema.Address, }), ) /** * Adds run-once manual credits to stored Earn reward accounts so the next * settlement pays them out. `--apply` requires a paused campaign with no run in * flight, because a settlement commit replaces `reward_accounts` wholesale. */ const creditRewardAccounts = incur_Cli.create('credit-reward-accounts', { description: 'Review or apply run-once credits to Earn reward accounts.', env: z.object({ DATABASE_URL: z .string() .optional() .describe('Postgres connection string. Used when --database-url is omitted.'), }), options: z.object({ apply: z.boolean().default(false).describe('Write the reviewed credits. Defaults to dry-run.'), chainId: z.number().int().default(4217).describe('Chain containing the vault.'), databaseUrl: z .string() .optional() .describe('Postgres connection string. Overrides DATABASE_URL.'), dbSchema: z.string().optional().describe('Postgres schema containing the API tables.'), file: z .string() .describe('JSON file of `{ recipient, assets }` credits with assets in base units.'), reference: z .string() .min(1) .describe('Batch reference. A recipient is never credited twice under one reference.'), vaultAddress: z.string().describe('EarnVault address of the campaign.'), }), async run(c) { const parsed = rewardCreditBatch.safeParse( (() => { try { return JSON.parse(fs.readFileSync(c.options.file, 'utf8')) } catch { return undefined } })(), ) if (!parsed.success) return c.error({ code: 'invalid_reward_credit_batch', message: 'The credit file must contain an array of { recipient, assets } with positive integer base units.', }) const credits = parsed.data.map((credit) => ({ assets: BigInt(credit.assets), recipient: credit.recipient, })) const connectionString = c.options.databaseUrl ?? c.env.DATABASE_URL if (!connectionString) return c.error({ code: 'missing_database_url', message: 'Set --database-url or DATABASE_URL to the Postgres connection string.', }) const vaultAddress = Schema.Address.safeParse(c.options.vaultAddress) if (!vaultAddress.success) return c.error({ code: 'invalid_vault_address', message: 'The vault address must be a 20-byte hex address.', }) const db = c.options.dbSchema ? Db.postgres({ connectionString, schema: c.options.dbSchema }) : Db.postgres({ connectionString }) const options = { chainId: c.options.chainId, credits, db, reference: c.options.reference, vaultAddress: vaultAddress.data, } try { const report = await Credits.review(options) const summary = { appliedAssets: report.appliedAssets.toString(), blockers: report.blockers, latestRunPhase: report.latestRunPhase, lines: report.lines.map((line) => ({ ...line, assets: line.assets.toString() })), missing: report.missing, paused: report.paused, pendingAssets: report.pendingAssets.toString(), ready: report.ready, } if (!c.options.apply) return { applied: false, ...summary } const result = await Credits.apply(options) return { applied: true, credited: result.credited, creditedAssets: result.creditedAssets.toString(), replayed: result.replayed, ...summary, } } catch (error) { if (error instanceof Credits.CreditsError) return c.error({ code: 'reward_credit_unavailable', message: error.message }) /* v8 ignore next */ const message = error instanceof Error ? error.message : 'Credit failed.' return c.error({ code: 'reward_credit_failed', message }) } finally { await db.close() } }, }) /** * Super admin commands that run against a deployment's infrastructure rather * than the hosted API (database migrations, …). */ export const admin = incur_Cli .create('admin', { description: 'Super admin commands for a Tempo API deployment.', }) .command(auditRewardAccounts) .command(creditRewardAccounts) .command(importRewardEligibility) .command(migrate) /** * Resolves ClickHouse migrate config. Credentials gate the migration (absent * → skip); empty values count as unset since CI renders missing secrets as * empty strings. Partial config returns the missing variable names instead. */ function clickhouseConfig(input: { database?: string | undefined password?: string | undefined url?: string | undefined user?: string | undefined }): | { missing: readonly string[]; options?: undefined } | { missing?: undefined; options: Analytics.clickhouse.Options } | undefined { const database = input.database || undefined const password = input.password || undefined const url = input.url || undefined const user = input.user || undefined if (password === undefined && user === undefined) return undefined if (database === undefined || password === undefined || url === undefined || user === undefined) return { missing: [ ...(database === undefined ? ['CLICKHOUSE_DATABASE'] : []), ...(password === undefined ? ['CLICKHOUSE_MIGRATE_PASSWORD'] : []), ...(user === undefined ? ['CLICKHOUSE_MIGRATE_USER'] : []), ...(url === undefined ? ['CLICKHOUSE_URL'] : []), ], } return { options: { database, password, url, user } } }