import type { ColumnType, Selectable } from 'kysely' import { sql } from 'kysely' import type { Address } from 'viem' import type * as Db from '../Db.js' import type * as db_Schema from '../Schema.js' /** Columns of the `reward_credits` table. */ export type Table = Omit & { /** Bigint chain id, read as a string from pg and written as a number. */ chainId: ColumnType } /** A stored manual reward credit. */ export type Record = db_Schema.RewardCredit /** * Records one credit and adds it to the recipient's pending rewards atomically. * A repeated `(recipient, reference)` is a no-op, so a batch can be replayed safely. */ export async function apply(db: Db.Db, options: apply.Options): Promise { return db.transaction(async (tx) => { const inserted = await tx.kysely .insertInto('reward_credits') .values({ assets: options.assets.toString(), chainId: options.chainId, createdAt: new Date().toISOString(), recipient: normalize(options.recipient), reference: options.reference, vaultAddress: normalize(options.vaultAddress), }) .onConflict((oc) => oc.columns(['chainId', 'vaultAddress', 'recipient', 'reference']).doNothing(), ) .returning('assets') .executeTakeFirst() if (!inserted) return { credited: false } const updated = await tx.kysely .updateTable('reward_accounts') .set({ pendingRewardAssets: sql`reward_accounts.pending_reward_assets + ${options.assets.toString()}::numeric`, }) .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) .where('recipient', '=', normalize(options.recipient)) .executeTakeFirst() // Crediting an unknown account would strand assets nobody can settle; roll back. if (updated.numUpdatedRows !== 1n) throw new MissingAccountError(options.recipient) return { credited: true } }) } export declare namespace apply { /** One credit to record. */ type Options = { /** Base-asset reward units to add. */ assets: bigint /** Chain containing the vault. */ chainId: number /** Registered recipient with a stored `reward_accounts` row. */ recipient: string /** Batch reference; the same reference never credits a recipient twice. */ reference: string /** EarnVault address. */ vaultAddress: string } /** Whether this call recorded the credit or found it already applied. */ type Result = { credited: boolean } } /** Lists one campaign's credits, optionally for one reference. */ export async function list(db: Db.Db, options: list.Options): Promise { let query = db.kysely .selectFrom('reward_credits') .selectAll() .where('chainId', '=', String(options.chainId)) .where('vaultAddress', '=', normalize(options.vaultAddress)) if (options.reference !== undefined) query = query.where('reference', '=', options.reference) const rows = await query.orderBy('createdAt', 'asc').orderBy('recipient', 'asc').execute() return rows.map(toRecord) } export declare namespace list { /** Campaign identity and optional batch filter. */ type Options = { /** Chain containing the vault. */ chainId: number /** Batch reference to filter by. */ reference?: string | undefined /** EarnVault address. */ vaultAddress: string } } function normalize(address: string): Address { return address.toLowerCase() as Address } function toRecord(row: Selectable): Record { return { ...row, chainId: Number(row.chainId) } } /** Thrown when a credit targets a recipient without a stored reward account. */ export class MissingAccountError extends Error { override name = 'RewardCredits.MissingAccountError' constructor(recipient: string) { super(`Reward account ${recipient} does not exist.`) } }