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 RewardEligibilityAssociations from '../../db/tables/rewardEligibilityAssociations.js' import type * as Tidx from '../Tidx.js' import type * as Campaigns from './Campaigns.js' import * as Events from './Events.js' import * as Projection from './Projection.js' /** Stored and replayed values of one projected balance. */ export type Comparison = { /** Value a fresh historical replay produces. */ expected: string /** Value currently persisted in `reward_accounts`. */ stored: string } /** One registered recipient whose stored projection differs from its replay. */ export type Discrepancy = { /** Principal permanently allocated against campaign caps. */ allocatedPrincipalAssets: Comparison /** Attributable public EarnShare balance. */ publicEarnShares: Comparison /** Qualified EarnShare balance. */ qualifiedEarnShares: Comparison /** Registered recipient. */ recipient: Address } /** Result of comparing one campaign's stored projections against replayed history. */ export type Report = { /** Total principal allocated across every stored and every replayed account. */ allocatedPrincipalAssets: Comparison /** Campaign identity and the cursor the replay was bounded to. */ campaign: { /** Chain containing the vault. */ chainId: number /** Last event applied by the delivered projection. */ eventCursor: Campaigns.EventCursor /** Whether the campaign is paused. */ paused: boolean /** Per-user principal cap in base-asset units. */ perUserPrincipalCapAssets: string /** Campaign-wide principal cap in base-asset units. */ totalPrincipalCapAssets: string /** EarnVault address. */ vaultAddress: Address } /** Accounts whose stored balances differ from replay without an allocation shortfall. */ drift: readonly Discrepancy[] /** Accounts whose replay allocates more principal than is stored: deposits the projection never applied. */ missingDeposits: readonly Discrepancy[] /** Stored account count. */ storedAccounts: number /** Eligible registrations for the campaign. */ registrations: number /** Registered recipients without a stored account; the next run replays them. */ unprojected: readonly Address[] } /** * Replays every eligible registration's deposit and EarnShare history through * the campaign's committed cursor and compares it with the stored projections. */ export async function audit(options: audit.Options): Promise { const { db, tidx } = options const campaign = await RewardCampaigns.get(db, options) if (!campaign) throw new AuditError('Reward campaign not found.') const boost = campaign.config.boostRewards if (!boost) throw new AuditError('Reward campaign has no boost rewards to audit.') if (!campaign.eventCursor) throw new AuditError('Reward campaign has not delivered a run yet.') const eventCursor = campaign.eventCursor const eligible = await allEligible(db, campaign) const registrations = eligible.map((entry) => ({ recipient: getAddress(entry.walletAddress), registeredAt: entry.latestRegisteredAt, registrationOrder: BigInt(entry.registrationOrder), })) const stored = await RewardAccounts.list(db, campaign) const storedByRecipient = new Map( stored.map((account) => [account.recipient.toLowerCase(), account]), ) // Replay from an empty projection so every registration is rebuilt from history. Exclusions are // skipped: they only remove protocol contracts, which cannot register as eligible wallets. const replayed = registrations.length === 0 ? [] : ( await Projection.reconcilePages({ accounts: [], eligible: registrations, excluded: [], pages: () => boundedPages( Events.accountEventPages(tidx, { accounts: registrations.map((entry) => entry.recipient), earnShare: campaign.earnShareAddress, earnVault: campaign.vaultAddress, throughBlock: eventCursor.blockNumber, }), eventCursor, ), perUserPrincipalCapAssets: BigInt(boost.perUserPrincipalCapAssets), principalAllocationOrder: 'deposit', totalPrincipalCapAssets: BigInt(boost.totalPrincipalCapAssets), }) ).accounts const missingDeposits: Discrepancy[] = [] const drift: Discrepancy[] = [] const unprojected: Address[] = [] for (const account of replayed) { const record = storedByRecipient.get(account.recipient.toLowerCase()) if (!record) { unprojected.push(account.recipient) continue } const discrepancy: Discrepancy = { allocatedPrincipalAssets: compare( record.allocatedPrincipalAssets, account.allocatedPrincipalAssets, ), publicEarnShares: compare(record.publicEarnShares, account.publicEarnShares), qualifiedEarnShares: compare(record.qualifiedEarnShares, account.qualifiedEarnShares), recipient: account.recipient, } if (account.allocatedPrincipalAssets > BigInt(record.allocatedPrincipalAssets)) missingDeposits.push(discrepancy) else if ( discrepancy.allocatedPrincipalAssets.expected !== discrepancy.allocatedPrincipalAssets.stored || discrepancy.publicEarnShares.expected !== discrepancy.publicEarnShares.stored || discrepancy.qualifiedEarnShares.expected !== discrepancy.qualifiedEarnShares.stored ) drift.push(discrepancy) } return { allocatedPrincipalAssets: compare( stored.reduce((sum, account) => sum + BigInt(account.allocatedPrincipalAssets), 0n), replayed.reduce((sum, account) => sum + account.allocatedPrincipalAssets, 0n), ), campaign: { chainId: campaign.chainId, eventCursor, paused: campaign.paused, perUserPrincipalCapAssets: boost.perUserPrincipalCapAssets, totalPrincipalCapAssets: boost.totalPrincipalCapAssets, vaultAddress: campaign.vaultAddress, }, drift, missingDeposits, registrations: registrations.length, storedAccounts: stored.length, unprojected, } } export declare namespace audit { /** Campaign selector and data sources. */ type Options = { /** Chain containing the vault. */ chainId: number /** Authoritative Postgres connection. */ db: Db.Db /** TIDX client for the campaign chain. */ tidx: Tidx.Client /** EarnVault address. */ vaultAddress: string } } /** * Clears deposit-derived state for the given recipients so the next reward run * replays their history through the registration reconciliation path. */ export async function reset(options: reset.Options): Promise { return RewardAccounts.resetPrincipal(options.db, options) } export declare namespace reset { /** Recipients to rebuild on the next run. */ type Options = RewardAccounts.resetPrincipal.Options & { /** Authoritative Postgres connection. */ db: Db.Db } } /** Drops events past the committed cursor; pages are block-bounded, the cursor is log-bounded. */ async function* boundedPages( pages: AsyncIterable, cursor: Campaigns.EventCursor, ): AsyncGenerator { for await (const page of pages) { const events = page.filter((event) => compareCursors(event.cursor, cursor) <= 0) if (events.length > 0) yield { events } } } async function allEligible(db: Db.Db, campaign: RewardCampaigns.Record) { const records: RewardEligibilityAssociations.Record[] = [] let cursor: string | undefined do { const page = await RewardEligibilityAssociations.list(db, { chainId: campaign.chainId, ...(cursor ? { cursor } : {}), limit: 1_000, vaultAddress: campaign.vaultAddress, }) records.push(...page.data) cursor = page.nextCursor ?? undefined } while (cursor) return records } function compare(stored: bigint | string, expected: bigint): Comparison { return { expected: expected.toString(), stored: stored.toString() } } function compareCursors(left: Campaigns.EventCursor, right: Campaigns.EventCursor): number { if (left.blockNumber !== right.blockNumber) return left.blockNumber < right.blockNumber ? -1 : 1 if (left.transactionIndex !== right.transactionIndex) return left.transactionIndex < right.transactionIndex ? -1 : 1 if (left.logIndex !== right.logIndex) return left.logIndex < right.logIndex ? -1 : 1 return 0 } /** The campaign cannot be audited in its current state. */ export class AuditError extends Error { override name = 'Audit.AuditError' }