import { getAddress, type Address } from 'viem' import type * as Db from '../../db/Db.js' import * as RewardAccounts from '../../db/tables/rewardAccounts.js' import * as RewardCampaigns from '../../db/tables/rewardCampaigns.js' import * as RewardCredits from '../../db/tables/rewardCredits.js' import * as RewardRuns from '../../db/tables/rewardRuns.js' /** One requested manual credit. */ export type Credit = { /** Base-asset reward units to add to pending rewards. */ assets: bigint /** Registered recipient. */ recipient: Address } /** Requested credit annotated with whether it can be applied. */ export type Line = Credit & { /** * `pending` has a stored account and no credit under this reference yet; `applied` was already * credited under this reference; `missing_account` has no `reward_accounts` row to receive it. */ status: 'applied' | 'missing_account' | 'pending' } /** Result of examining one credit batch against the campaign's stored state. */ export type Report = { /** Credits already applied under this reference; replaying them is a no-op. */ appliedAssets: bigint /** Why `ready` is false, for operator output. */ blockers: readonly string[] /** Latest run phase, or null when the campaign never ran. */ latestRunPhase: string | null /** Requested credits with their per-recipient status. */ lines: readonly Line[] /** Recipients without a stored reward account. */ missing: readonly Address[] /** Whether the campaign is paused; settlement must not commit while credits are written. */ paused: boolean /** Credits that would be written by `apply`. */ pendingAssets: bigint /** Whether `apply` would proceed: paused, no run in flight, and no missing accounts. */ ready: boolean } /** Run phases after which no settlement commit can overwrite `reward_accounts`. */ const settledPhases = new Set(['delivered', 'failed']) /** Examines a credit batch without writing anything. */ export async function review(options: review.Options): Promise { const { credits, db, reference } = options const campaign = await RewardCampaigns.get(db, options) if (!campaign) throw new CreditsError('Reward campaign not found.') if (!campaign.config.boostRewards) throw new CreditsError('Reward campaign has no boost rewards to credit.') const duplicate = findDuplicate(credits) if (duplicate) throw new CreditsError(`Recipient ${duplicate} appears more than once.`) const latest = await RewardRuns.latestSummary(db, options) const latestRunPhase = latest?.phase ?? null const accounts = new Set( (await RewardAccounts.list(db, options)).map((account) => account.recipient.toLowerCase()), ) const applied = new Set( (await RewardCredits.list(db, { ...options, reference })).map((credit) => credit.recipient.toLowerCase(), ), ) const lines = credits.map((credit): Line => { const key = credit.recipient.toLowerCase() if (applied.has(key)) return { ...credit, status: 'applied' } if (!accounts.has(key)) return { ...credit, status: 'missing_account' } return { ...credit, status: 'pending' } }) const missing = lines .filter((line) => line.status === 'missing_account') .map((line) => line.recipient) const blockers: string[] = [] if (!campaign.paused) blockers.push('Campaign is not paused.') if (latestRunPhase !== null && !settledPhases.has(latestRunPhase)) blockers.push(`Latest run is still in phase "${latestRunPhase}".`) if (missing.length > 0) blockers.push(`${missing.length} recipient(s) have no reward account.`) return { appliedAssets: sum(lines.filter((line) => line.status === 'applied')), blockers, latestRunPhase, lines, missing, paused: campaign.paused, pendingAssets: sum(lines.filter((line) => line.status === 'pending')), ready: blockers.length === 0, } } export declare namespace review { /** Campaign identity and the credit batch to examine. */ type Options = { /** Chain containing the vault. */ chainId: number /** Requested credits. */ credits: readonly Credit[] /** Database owning the campaign. */ db: Db.Db /** Batch reference that makes the credits run-once. */ reference: string /** EarnVault address. */ vaultAddress: string } } /** * Writes every pending credit into `pending_reward_assets`. Refuses unless `review` reports the * campaign ready, because a settlement commit replaces `reward_accounts` wholesale. */ export async function apply(options: apply.Options): Promise { const { chainId, credits, db, reference, vaultAddress } = options const report = await review(options) if (!report.ready) throw new CreditsError(`Cannot apply credits: ${report.blockers.join(' ')}`) let credited = 0 let replayed = 0 for (const credit of credits) { const result = await RewardCredits.apply(db, { assets: credit.assets, chainId, recipient: credit.recipient, reference, vaultAddress, }) if (result.credited) credited += 1 else replayed += 1 } return { credited, creditedAssets: report.pendingAssets, replayed } } export declare namespace apply { /** Same inputs as `review`. */ type Options = review.Options /** Counts of what this call wrote versus found already written. */ type Result = { /** Recipients credited by this call. */ credited: number /** Base-asset units written by this call. */ creditedAssets: bigint /** Recipients already credited under this reference before this call. */ replayed: number } } function findDuplicate(credits: readonly Credit[]): Address | undefined { const seen = new Set() for (const credit of credits) { const key = credit.recipient.toLowerCase() if (seen.has(key)) return getAddress(credit.recipient) seen.add(key) } return undefined } function sum(lines: readonly Line[]): bigint { return lines.reduce((total, line) => total + line.assets, 0n) } /** Thrown when a credit batch cannot be examined or applied. */ export class CreditsError extends Error { override name = 'Credits.CreditsError' }