import { sql, type ColumnType, type JSONColumnType, type Selectable } from 'kysely' import type { Address } from 'viem' import * as Campaigns from '../../internal/rewards/Campaigns.js' import type * as Db from '../Db.js' import type * as db_Schema from '../Schema.js' import * as RewardCampaignDistributors from './rewardCampaignDistributors.js' /** Columns of the `reward_campaigns` table. */ export type Table = Omit< db_Schema.RewardCampaign, 'chainId' | 'config' | 'eventCursor' | 'pendingConfig' > & { /** Bigint chain id, read as a string from pg and written as a number. */ chainId: ColumnType /** Active campaign configuration. */ config: JSONColumnType /** Last applied TIDX event. */ eventCursor: JSONColumnType /** Configuration scheduled at a future boundary. */ pendingConfig: JSONColumnType } /** A stored reward campaign. */ export type Record = db_Schema.RewardCampaign /** A requested campaign configuration cannot safely replace the stored configuration. */ export class ConfigurationError extends Error { override name = 'RewardCampaigns.ConfigurationError' } /** Reads one campaign by chain and EarnVault. */ export async function get(db: Db.Db, options: get.Options): Promise { const row = await db.kysely .selectFrom('reward_campaigns') .selectAll() .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .executeTakeFirst() return row ? toRecord(row) : undefined } export declare namespace get { /** Campaign identity. */ type Options = { /** Chain containing the vault. */ chainId: number /** EarnVault address. */ vaultAddress: string } } /** Reads campaigns for one bounded set of EarnVaults. */ export async function listByVaults(db: Db.Db, options: listByVaults.Options): Promise { const vaultAddresses = [...new Set(options.vaultAddresses.map(normalize))] if (vaultAddresses.length === 0) return [] const rows = await db.kysely .selectFrom('reward_campaigns') .selectAll() .where('chainId', '=', String(options.chainId)) .where('vaultAddress', 'in', vaultAddresses) .execute() return rows.map(toRecord) } export declare namespace listByVaults { /** Bounded campaign selector. */ type Options = { /** Chain containing every vault. */ chainId: number /** EarnVault addresses. */ vaultAddresses: readonly string[] } } /** Creates a campaign or schedules its complete replacement configuration. */ export async function upsert(db: Db.Db, options: upsert.Options): Promise { return db.transaction(async (tx) => { const existing = await tx.kysely .selectFrom('reward_campaigns') .selectAll() .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .forUpdate() .executeTakeFirst() const now = new Date().toISOString() if (!existing) { const start = Math.min( options.config.targetYield?.startTimestamp ?? Number.MAX_SAFE_INTEGER, options.config.boostRewards?.startTimestamp ?? Number.MAX_SAFE_INTEGER, ) const row = await tx.kysely .insertInto('reward_campaigns') .values({ assetAddress: normalize(options.assetAddress), assetDecimals: options.assetDecimals, chainId: options.chainId, config: JSON.stringify(options.config), controllerAddress: null, createdAt: now, deliveredThrough: String(start), distributorAddress: null, earnShareAddress: normalize(options.earnShareAddress), earnShareDecimals: options.earnShareDecimals, eventCursor: null, paused: false, pendingConfig: null, pendingEffectiveAt: null, provisioningError: null, signerAddress: null, updatedAt: now, vaultAddress: normalize(options.vaultAddress), }) .returningAll() .executeTakeFirstOrThrow() return toRecord(row) } const record = toRecord(existing) validateProvisionedBoostBindings(record, options.config) if (canonicalConfig(record.config) === canonicalConfig(options.config)) { if (record.pendingConfig === null) return record const row = await tx.kysely .updateTable('reward_campaigns') .set({ pendingConfig: null, pendingEffectiveAt: null, provisioningError: hasRequiredBindings(record, options.config) ? null : record.provisioningError, updatedAt: now, }) .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .returningAll() .executeTakeFirstOrThrow() return toRecord(row) } const started = Boolean( await tx.kysely .selectFrom('reward_runs') .select('id') .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .limit(1) .executeTakeFirst(), ) if (!started && options.now < campaignStart(record.config)) { const start = campaignStart(options.config) if (start <= options.now) throw new ConfigurationError('the replacement reward campaign must start in the future') const row = await tx.kysely .updateTable('reward_campaigns') .set({ config: JSON.stringify(options.config), deliveredThrough: String(start), pendingConfig: null, pendingEffectiveAt: null, provisioningError: hasRequiredBindings(record, options.config) ? null : record.provisioningError, updatedAt: now, }) .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .returningAll() .executeTakeFirstOrThrow() return toRecord(row) } if (options.now >= Campaigns.finalBoundary(record.config)) throw new ConfigurationError('an ended reward campaign cannot be extended') if (record.pendingEffectiveAt !== null && Number(record.pendingEffectiveAt) <= options.now) throw new ConfigurationError( 'the pending reward configuration must be promoted before another update', ) const intervalSeconds = Campaigns.intervalSeconds(record.config) const activeRuns = await tx.kysely .selectFrom('reward_runs') .select('endsAt') .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .where('phase', 'not in', ['delivered', 'failed']) .execute() const effectiveAt = Math.max( Campaigns.nextBoundary({ intervalSeconds, // Delivered-through remains on the original campaign grid even if its earliest schedule is removed. origin: Number(record.deliveredThrough), timestamp: options.now, }), ...activeRuns.map((run) => Number(run.endsAt)), ) if (Campaigns.intervalSeconds(options.config) !== intervalSeconds) throw new ConfigurationError('intervalSeconds cannot change after campaign start') validateBoostBindings(record.config, options.config, effectiveAt) validateScheduleUpdate(record.config, options.config, effectiveAt) const row = await tx.kysely .updateTable('reward_campaigns') .set({ pendingConfig: JSON.stringify(options.config), pendingEffectiveAt: String(effectiveAt), updatedAt: now, }) .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .returningAll() .executeTakeFirstOrThrow() return toRecord(row) }) } export declare namespace upsert { /** Verified vault and complete configuration. */ type Options = { /** Vault base-asset address. */ assetAddress: Address /** Vault base-asset decimals. */ assetDecimals: number /** Chain containing the vault. */ chainId: number /** Complete campaign configuration. */ config: Campaigns.Config /** Current Unix timestamp used to choose the update boundary. */ now: number /** Vault EarnShare address. */ earnShareAddress: Address /** Vault EarnShare decimals. */ earnShareDecimals: number /** EarnVault address. */ vaultAddress: Address } } /** Changes the campaign's operational scheduler pause. */ export async function setPaused( db: Db.Db, options: setPaused.Options, ): Promise { const row = await db.kysely .updateTable('reward_campaigns') .set({ paused: options.paused, updatedAt: new Date().toISOString() }) .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .returningAll() .executeTakeFirst() return row ? toRecord(row) : undefined } export declare namespace setPaused { /** Campaign identity and pause state. */ type Options = { /** Chain containing the vault. */ chainId: number /** Desired scheduler pause state. */ paused: boolean /** EarnVault address. */ vaultAddress: string } } /** Permanently binds one campaign to its first signer account. */ export async function bindSigner( db: Db.Db, options: bindSigner.Options, ): Promise { const signerAddress = normalize(options.signerAddress) const row = await db.kysely .updateTable('reward_campaigns') .set({ signerAddress, updatedAt: new Date().toISOString() }) .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .where((eb) => eb.or([eb('signerAddress', 'is', null), eb('signerAddress', '=', signerAddress)]), ) .returningAll() .executeTakeFirst() return row ? toRecord(row) : undefined } export declare namespace bindSigner { /** Campaign identity and signer account. */ type Options = { /** Chain containing the vault. */ chainId: number /** Signer permanently associated with the campaign. */ signerAddress: Address /** EarnVault address. */ vaultAddress: Address } } /** Records verified deterministic reward periphery bindings. */ export async function setBindings( db: Db.Db, options: setBindings.Options, ): Promise { return db.transaction(async (tx) => { let query = tx.kysely .updateTable('reward_campaigns') .set({ ...(options.controllerAddress ? { controllerAddress: options.controllerAddress } : {}), ...(options.distributorAddress ? { distributorAddress: options.distributorAddress } : {}), ...(options.error !== undefined ? { provisioningError: options.error } : {}), updatedAt: new Date().toISOString(), }) .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) if (options.config) query = query.where((eb) => eb.or([ eb('config', '=', JSON.stringify(options.config!) as never), eb('pendingConfig', '=', JSON.stringify(options.config!) as never), ]), ) if (options.signerAddress) query = query.where('signerAddress', '=', normalize(options.signerAddress)) const row = await query.returningAll().executeTakeFirst() if (!row) return undefined if (options.distributorAddress) await RewardCampaignDistributors.add(tx, { chainId: options.chainId, distributorAddress: options.distributorAddress, vaultAddress: options.vaultAddress, }) return toRecord(row) }) } export declare namespace setBindings { /** Verified periphery bindings for one campaign. */ type Options = { /** Chain containing the vault. */ chainId: number /** Target-yield controller, when configured. */ controllerAddress?: Address | undefined /** Configuration whose immutable periphery is being recorded. */ config?: Campaigns.Config | undefined /** Merkle distributor, when configured. */ distributorAddress?: Address | undefined /** Actionable provisioning error. */ error?: string | null | undefined /** Signer owning the deterministic periphery. */ signerAddress?: Address | undefined /** EarnVault address. */ vaultAddress: Address } } /** Records the latest campaign provisioning failure without changing bindings. */ export async function setProvisioningError( db: Db.Db, options: setProvisioningError.Options, ): Promise { const row = await db.kysely .updateTable('reward_campaigns') .set({ provisioningError: options.error, updatedAt: new Date().toISOString() }) .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .where((eb) => eb.or([ eb('config', '=', JSON.stringify(options.config) as never), eb('pendingConfig', '=', JSON.stringify(options.config) as never), ]), ) .where( sql`(${Boolean(options.config.targetYield)} AND controller_address IS NULL) OR (${Boolean(options.config.boostRewards)} AND distributor_address IS NULL)`, ) .returningAll() .executeTakeFirst() return row ? toRecord(row) : undefined } export declare namespace setProvisioningError { /** Campaign identity and current failure. */ type Options = { /** Chain containing the vault. */ chainId: number /** Configuration observed by the failed provisioning attempt. */ config: Campaigns.Config /** Actionable provisioning failure. */ error: string /** EarnVault address. */ vaultAddress: Address } } /** Advances one campaign after every payout bundle reaches a terminal result. */ export async function advance(db: Db.Db, options: advance.Options): Promise { const currentRow = await db.kysely .selectFrom('reward_campaigns') .selectAll() .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .forUpdate() .executeTakeFirst() const current = currentRow ? toRecord(currentRow) : undefined if (!current || current.deliveredThrough !== String(options.expectedDeliveredThrough)) return undefined const promote = current.pendingConfig !== null && current.pendingEffectiveAt !== null && BigInt(current.pendingEffectiveAt) <= BigInt(options.deliveredThrough) const row = await db.kysely .updateTable('reward_campaigns') .set({ ...(promote ? { config: JSON.stringify(current.pendingConfig), pendingConfig: null, pendingEffectiveAt: null, } : {}), deliveredThrough: String(options.deliveredThrough), ...(options.eventCursor ? { eventCursor: JSON.stringify(options.eventCursor) } : {}), updatedAt: new Date().toISOString(), }) .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .where('deliveredThrough', '=', String(options.expectedDeliveredThrough)) .returningAll() .executeTakeFirst() return row ? toRecord(row) : undefined } export declare namespace advance { /** Fenced delivered cursor update. */ type Options = { /** Chain containing the vault. */ chainId: number /** Newly delivered boundary. */ deliveredThrough: number /** Final applied TIDX event. */ eventCursor?: Campaigns.EventCursor | undefined /** Previously delivered boundary. */ expectedDeliveredThrough: number /** EarnVault address. */ vaultAddress: string } } /** Lists campaigns requiring work at or before one Unix timestamp. */ export async function listDue(db: Db.Db, options: listDue.Options): Promise { const rows = await db.kysely .selectFrom('reward_campaigns') .selectAll() .where('paused', '=', false) .where('deliveredThrough', '<', String(options.through)) .where( sql`delivered_through < GREATEST(COALESCE((config -> 'targetYield' ->> 'startTimestamp')::bigint + (((config -> 'targetYield' ->> 'endTimestamp')::bigint - (config -> 'targetYield' ->> 'startTimestamp')::bigint) / (config -> 'targetYield' ->> 'intervalSeconds')::bigint) * (config -> 'targetYield' ->> 'intervalSeconds')::bigint, 0), COALESCE((config -> 'boostRewards' ->> 'startTimestamp')::bigint + (((config -> 'boostRewards' ->> 'endTimestamp')::bigint - (config -> 'boostRewards' ->> 'startTimestamp')::bigint) / (config -> 'boostRewards' ->> 'intervalSeconds')::bigint) * (config -> 'boostRewards' ->> 'intervalSeconds')::bigint, 0))`, ) .orderBy('deliveredThrough', 'asc') .orderBy('chainId', 'asc') .orderBy('vaultAddress', 'asc') .limit(options.limit) .offset(options.offset ?? 0) .execute() return rows.map(toRecord) } export declare namespace listDue { /** Due-campaign scan bounds. */ type Options = { /** Maximum campaigns returned. */ limit: number /** Number of earlier due campaigns skipped. */ offset?: number | undefined /** Unix timestamp through which work is due. */ through: number } } /** Lists active campaigns whose deterministic reward periphery is not recorded yet. */ export async function listUnprovisioned( db: Db.Db, options: listUnprovisioned.Options, ): Promise { const rows = await db.kysely .selectFrom('reward_campaigns') .selectAll() .where('paused', '=', false) .where( sql`((((config ? 'targetYield') OR (pending_config ? 'targetYield')) AND controller_address IS NULL) OR (((config ? 'boostRewards') OR (pending_config ? 'boostRewards')) AND distributor_address IS NULL))`, ) .orderBy('createdAt', 'asc') .orderBy('chainId', 'asc') .orderBy('vaultAddress', 'asc') .limit(options.limit) .offset(options.offset ?? 0) .execute() return rows.map(toRecord) } export declare namespace listUnprovisioned { /** Provisioning scan bounds. */ type Options = { /** Maximum campaigns returned. */ limit: number /** Number of earlier campaigns skipped. */ offset?: number | undefined } } function campaignStart(config: Campaigns.Config): number { return Math.min( config.targetYield?.startTimestamp ?? Number.MAX_SAFE_INTEGER, config.boostRewards?.startTimestamp ?? Number.MAX_SAFE_INTEGER, ) } function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]` if (value && typeof value === 'object') return `{${Object.entries(value) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`) .join(',')}}` return JSON.stringify(value) } function canonicalBoost(boost: Campaigns.Config['boostRewards']): string { if (!boost) return canonical(boost) const { enabled, ...rest } = boost return canonical(enabled === false ? { ...rest, enabled } : rest) } function canonicalConfig(config: Campaigns.Config): string { const boostRewards = config.boostRewards if (!boostRewards) return canonical(config) const { enabled, ...boost } = boostRewards return canonical({ ...config, boostRewards: enabled === false ? { ...boost, enabled } : boost, }) } function normalize(address: string): Address { return address.toLowerCase() as Address } function hasRequiredBindings(record: Record, config: Campaigns.Config): boolean { return ( (!config.targetYield || record.controllerAddress !== null) && (!config.boostRewards || record.distributorAddress !== null) ) } function toRecord(row: Selectable): Record { return { ...row, chainId: Number(row.chainId) } } function validateBoostBindings( current: Campaigns.Config, next: Campaigns.Config, effectiveAt: number, ): void { if (current.boostRewards && !next.boostRewards) throw new ConfigurationError('boostRewards cannot be removed after campaign creation') if (!current.boostRewards || !next.boostRewards) return if (current.boostRewards.treasury !== next.boostRewards.treasury) throw new ConfigurationError('boostRewards treasury is immutable after campaign creation') const boostStarted = current.boostRewards.startTimestamp < effectiveAt if (boostStarted && current.boostRewards.startTimestamp !== next.boostRewards.startTimestamp) throw new ConfigurationError( 'boostRewards startTimestamp is immutable after boost accrual begins', ) if ( boostStarted && (next.boostRewards.perUserPrincipalCapAssets !== current.boostRewards.perUserPrincipalCapAssets || next.boostRewards.totalPrincipalCapAssets !== current.boostRewards.totalPrincipalCapAssets) ) throw new ConfigurationError('boost principal caps are immutable after boost accrual begins') if ( boostStarted && canonical( current.boostRewards.excludedAddresses.map((address) => address.toLowerCase()).sort(), ) !== canonical(next.boostRewards.excludedAddresses.map((address) => address.toLowerCase()).sort()) ) throw new ConfigurationError('boost excludedAddresses are immutable after boost accrual begins') } function validateProvisionedBoostBindings(current: Record, next: Campaigns.Config): void { if (!current.distributorAddress) return const boundBoost = current.config.boostRewards ?? current.pendingConfig?.boostRewards if (!boundBoost || !next.boostRewards) throw new ConfigurationError('boostRewards cannot be removed after distributor provisioning') if (boundBoost.treasury !== next.boostRewards.treasury) throw new ConfigurationError( 'boostRewards treasury is immutable after distributor provisioning', ) } function validateScheduleUpdate( current: Campaigns.Config, next: Campaigns.Config, effectiveAt: number, ): void { const intervalSeconds = Campaigns.intervalSeconds(current) const origin = campaignStart(current) const starts = [next.targetYield?.startTimestamp, next.boostRewards?.startTimestamp].filter( (start): start is number => start !== undefined, ) if (starts.some((start) => (start - origin) % intervalSeconds !== 0)) throw new ConfigurationError('replacement schedules must remain on the active boundary grid') if (!current.targetYield && next.targetYield && next.targetYield.startTimestamp < effectiveAt) throw new ConfigurationError( 'new targetYield schedules must start at or after the update boundary', ) if (!current.boostRewards && next.boostRewards && next.boostRewards.startTimestamp < effectiveAt) throw new ConfigurationError( 'new boostRewards schedules must start at or after the update boundary', ) if ( current.targetYield && next.targetYield && current.targetYield.startTimestamp !== next.targetYield.startTimestamp && next.targetYield.startTimestamp < effectiveAt ) throw new ConfigurationError( 'replacement targetYield schedules must start at or after the update boundary', ) if ( current.boostRewards && next.boostRewards && current.boostRewards.startTimestamp !== next.boostRewards.startTimestamp && next.boostRewards.startTimestamp < effectiveAt ) throw new ConfigurationError( 'replacement boostRewards schedules must start at or after the update boundary', ) if ( next.targetYield && canonical(current.targetYield) !== canonical(next.targetYield) && Campaigns.finalBoundary({ targetYield: next.targetYield }) <= effectiveAt ) throw new ConfigurationError( 'targetYield must have a complete interval after the update boundary', ) if ( next.boostRewards && canonicalBoost(current.boostRewards) !== canonicalBoost(next.boostRewards) && Campaigns.finalBoundary({ boostRewards: next.boostRewards }) <= effectiveAt ) throw new ConfigurationError( 'boostRewards must have a complete interval after the update boundary', ) }