import { getAddress, parseAbi, type Address, type Hex } from 'viem' import { Value as core_Value } from 'ox' import * as Db from '../../db/Db.js' import * as RewardAccounts from '../../db/tables/rewardAccounts.js' import * as RewardCampaigns from '../../db/tables/rewardCampaigns.js' import * as RewardRuns from '../../db/tables/rewardRuns.js' import * as Campaigns from './Campaigns.js' import type * as Viem from '../Viem.js' const distributorAbi = parseAbi([ 'function cumulativePaid(address recipient) view returns (uint256)', ]) /** One active Merkle reward campaign paid in EarnShare. */ export type Reward = { /** EarnShare token distributed as the reward. */ asset: { /** EarnShare address. */ address: Address /** Tempo chain id. */ chainId: number /** EarnShare decimals. */ decimals: number } /** Last calculated Unix boundary. */ calculatedThrough?: number | undefined /** Public campaign id, equal to the EarnVault address. */ campaignId: string /** Latest measured boost APR in basis points. */ currentRewardsAprBps: number | null /** Exclusive configured end timestamp. */ endsAt: number /** Inclusive configured start timestamp. */ startsAt: number /** Configured target total APR in basis points. */ targetTotalAprBps: number } /** One recipient's current cumulative reward state and proof. */ export type Recipient = { /** Cumulative EarnShare entitlement. */ cumulativeEntitlement: string /** Cumulative EarnShare already paid. */ cumulativePaid: string /** Current delivery exception, when present. */ deferral?: string | undefined /** Pending unpaid EarnShare. */ pending: string /** Proof against the latest confirmed root, when entitled. */ proof?: { /** Cumulative amount committed by the leaf. */ cumulativeAmount: string /** EarnShare distributor accepting the recovery claim. */ distributorAddress: Address /** Merkle proof. */ proof: readonly Hex[] /** Confirmed root version. */ rootVersion: string /** Statement content hash. */ statementHash: Hex } } /** Effective APY combining live position amounts with the latest delivered reward qualification. */ export type PositionApy = { /** Position amounts grouped by the annual rate they receive. */ breakdown: readonly { /** Position amount in vault asset units. */ assetAmount: { /** Amount in the asset's smallest unit. */ baseUnits: string /** Asset decimal places. */ decimals: number /** Amount rendered in whole asset units. */ formatted: string } /** Annual rate applied to this amount, as a decimal ratio. */ net: string /** Whether this amount receives the base or qualified boost rate. */ type: 'base' | 'boost' }[] /** Balance-weighted annual rate for the wallet's position, as a decimal ratio. */ net: string } /** Reads active rewards for one EarnVault. */ export async function get(options: get.Options): Promise { const db = Db.get(options.db) const campaign = await RewardCampaigns.get(db, options) const now = Math.floor(Date.now() / 1_000) if ( !campaign?.config.boostRewards || campaign.config.boostRewards.enabled === false || now >= campaign.config.boostRewards.endTimestamp ) return [] const run = await RewardRuns.latestDelivered(db, options) const reward = toReward(campaign, run, now) return reward ? [reward] : [] } export declare namespace get { /** Campaign selector. */ type Options = { /** Chain containing the vault. */ chainId: number /** Authoritative Postgres source. */ db: Db.Source /** EarnVault address. */ vaultAddress: string } } /** Reads active rewards for a vault page. */ export async function resolveCollection( options: resolveCollection.Options, ): Promise> { const db = Db.get(options.db) const vaultAddresses = [...new Set(options.vaults.map((vault) => vault.toLowerCase()))] const campaigns = await RewardCampaigns.listByVaults(db, { chainId: options.chainId, vaultAddresses, }) const now = Math.floor(Date.now() / 1_000) const active = campaigns.filter( (campaign) => campaign.config.boostRewards && campaign.config.boostRewards.enabled !== false && now < campaign.config.boostRewards.endTimestamp, ) const runs = await RewardRuns.listLatestDelivered(db, { chainId: options.chainId, vaultAddresses: active.map((campaign) => campaign.vaultAddress), }) const runsByVault = new Map(runs.map((run) => [run.vaultAddress, run])) const rewards = new Map(vaultAddresses.map((vault) => [vault, []])) for (const campaign of active) { const reward = toReward(campaign, runsByVault.get(campaign.vaultAddress), now) if (reward) rewards.set(campaign.vaultAddress, [reward]) } return rewards } export declare namespace resolveCollection { /** Vault-page selector. */ type Options = { /** Chain containing every vault. */ chainId: number /** Authoritative Postgres source. */ db: Db.Source /** EarnVault addresses. */ vaults: readonly string[] } } /** Reads one recipient's current state and latest proof. */ export async function recipient(options: recipient.Options): Promise { const db = Db.get(options.db) const [account, campaign, run] = await Promise.all([ RewardAccounts.get(db, options), RewardCampaigns.get(db, options), RewardRuns.latestStatement(db, options), ]) const entry = run?.statement?.entries.find( (candidate) => candidate.recipient.toLowerCase() === options.recipient.toLowerCase(), ) const proof = entry && run?.statement ? Campaigns.buildProof(run.statement, getAddress(options.recipient)) : undefined if (!account && !entry) return undefined const distributorAddress = run?.statement?.distributor ?? campaign?.distributorAddress const indexedPaid = BigInt(account?.cumulativePaid ?? 0) const entitlement = entry ? BigInt(entry.cumulativeAmount) : BigInt(account!.cumulativeEntitlement) const paid = options.getClient && distributorAddress ? await options.getClient(options.chainId).readContract({ abi: distributorAbi, address: distributorAddress, args: [getAddress(options.recipient)], functionName: 'cumulativePaid', }) : indexedPaid if (paid > entitlement) throw new Error('Onchain reward payment exceeds indexed entitlement.') return { cumulativeEntitlement: entitlement.toString(), cumulativePaid: paid.toString(), ...(account?.deferral && paid < entitlement ? { deferral: account.deferral } : {}), pending: (entitlement - paid).toString(), ...(entry && proof && run?.rootVersion && run.statementHash && run.statement ? { proof: { cumulativeAmount: entry.cumulativeAmount, distributorAddress: run.statement.distributor, proof, rootVersion: run.rootVersion, statementHash: run.statementHash, }, } : {}), } } export declare namespace recipient { /** Recipient state selector. */ type Options = { /** Chain containing the vault. */ chainId: number /** Authoritative Postgres source. */ db: Db.Source /** Chain-aware RPC client factory used to reconcile permissionless claims. */ getClient?: Viem.GetClient | undefined /** Registered recipient. */ recipient: string /** EarnVault address. */ vaultAddress: string } } /** Resolves effective APY from live position amounts and the latest delivered reward qualification. */ export async function positionApy(options: positionApy.Options): Promise { // Reject malformed live position data before querying persisted reward state. const baseRate = parseRatio(options.baseNet) if (baseRate === undefined) return null const amounts = (() => { try { return { shareBalance: BigInt(options.shareBalance), value: BigInt(options.value), } } catch { return undefined } })() if (amounts === undefined || amounts.shareBalance < 0n || amounts.value < 0n) return null const db = Db.get(options.db) const [account, campaign] = await Promise.all([ RewardAccounts.get(db, options), RewardCampaigns.get(db, options), ]) // Stored qualification applies only while the campaign is active and the recipient is eligible. const now = Math.floor(Date.now() / 1_000) const boost = campaign?.config.boostRewards // Excluded roles can retain qualified shares from before they became campaign infrastructure. const excluded = [ campaign?.controllerAddress, campaign?.distributorAddress, campaign?.earnShareAddress, campaign?.vaultAddress, boost?.treasury, ...(boost?.excludedAddresses ?? []), ...options.excludedAddresses, ].some((address) => address?.toLowerCase() === options.recipient.toLowerCase()) const boostEndsAt = boost && boost.startTimestamp + Math.floor((boost.endTimestamp - boost.startTimestamp) / boost.intervalSeconds) * boost.intervalSeconds const active = Boolean( boost && boost.enabled !== false && !excluded && boost.startTimestamp <= now && boostEndsAt !== undefined && now < boostEndsAt, ) const boostNet = active && boost ? ratio(boost.targetAnnualRateBps) : undefined const boostRate = boostNet === undefined ? undefined : parseRatio(boostNet) const qualifiedShares = BigInt(account?.qualifiedEarnShares ?? '0') const { shareBalance, value } = amounts // Qualification cannot exceed the wallet's live ownership. const boostAmount = (() => { if (boostRate === undefined || boostRate.value <= baseRate.value || shareBalance === 0n) return 0n const boostedShares = qualifiedShares < shareBalance ? qualifiedShares : shareBalance return (value * boostedShares) / shareBalance })() const baseAmount = value - boostAmount const breakdown: PositionApy['breakdown'][number][] = [] const assetDecimals = campaign?.assetDecimals ?? options.assetDecimals if (boostAmount > 0n && boostRate !== undefined && boostNet !== undefined) breakdown.push({ assetAmount: { baseUnits: boostAmount.toString(), decimals: assetDecimals, formatted: core_Value.format(boostAmount, assetDecimals), }, net: boostNet, type: 'boost', }) if (baseAmount > 0n || boostAmount === 0n) breakdown.push({ assetAmount: { baseUnits: baseAmount.toString(), decimals: assetDecimals, formatted: core_Value.format(baseAmount, assetDecimals), }, net: options.baseNet, type: 'base', }) // Weight each rate by its asset allocation to produce one position-level result. if (value === 0n) return { breakdown, net: options.baseNet } const weightedRate = baseRate.value * baseAmount + (boostRate?.value ?? 0n) * boostAmount return { breakdown, net: formatRatio(weightedRate, value, baseRate.scale) } } export declare namespace positionApy { /** Live position and latest delivered reward-state selector. */ type Options = { /** Fallback asset decimals when no reward campaign exists. */ assetDecimals: number /** Measured fee-aware vault APY. */ baseNet: string /** Chain containing the vault. */ chainId: number /** Authoritative Postgres source. */ db: Db.Source /** Current engine, fee, router, and Zone contract addresses excluded from boosts. */ excludedAddresses: readonly string[] /** Wallet receiving the position yield. */ recipient: string /** Current EarnShare balance. */ shareBalance: string /** Current asset value of the position. */ value: string /** EarnVault address. */ vaultAddress: string } } /** Reads current unpaid totals, reconciling permissionless claims from the distributor. */ export async function unpaid(options: unpaid.Options): Promise { const db = Db.get(options.db) const persisted = await RewardAccounts.list(db, options.campaign) const confirmed = await RewardRuns.latestStatement(db, options.campaign) const byRecipient = new Map( persisted.map((account) => [account.recipient.toLowerCase(), account]), ) const accounts = confirmed?.statement ? confirmed.statement.entries.map((entry) => { const account = byRecipient.get(entry.recipient.toLowerCase()) return { cumulativeEntitlement: entry.cumulativeAmount, cumulativePaid: account?.cumulativePaid ?? '0', recipient: entry.recipient, } }) : persisted const current = summarizeUnpaid(accounts) const pending = accounts.filter( (account) => BigInt(account.cumulativeEntitlement) > BigInt(account.cumulativePaid), ) if (!options.campaign.distributorAddress || pending.length === 0) return current const paidByRecipient = new Map() const client = options.getClient(options.campaign.chainId) for (const group of chunks(pending, 500)) { const paid = await client.multicall({ allowFailure: true, contracts: group.map((account) => ({ abi: distributorAbi, address: options.campaign.distributorAddress!, args: [getAddress(account.recipient)], functionName: 'cumulativePaid' as const, })), }) paid.forEach((result, index) => { if (result.status !== 'success') throw new Error('Reward payment reconciliation failed.') paidByRecipient.set(group[index]!.recipient.toLowerCase(), result.result) }) } return summarizeUnpaid( accounts.map((account) => ({ ...account, cumulativePaid: ( paidByRecipient.get(account.recipient.toLowerCase()) ?? BigInt(account.cumulativePaid) ).toString(), })), ) } export declare namespace unpaid { /** Live unpaid-summary dependencies. */ type Options = { /** Campaign whose current recipient projection is summarized. */ campaign: RewardCampaigns.Record /** Authoritative Postgres source. */ db: Db.Source /** Chain-aware RPC client factory used to reconcile permissionless claims. */ getClient: Viem.GetClient } } /** Converts integer basis points into a decimal APR ratio. */ export function ratio(bps: number): string { const value = BigInt(bps) return `${value / 10_000n}.${(value % 10_000n).toString().padStart(4, '0')}` } function latestBoostRate(run: RewardRuns.DeliveredSummary | undefined): number | null { return run?.evidence?.boostRateBps ?? null } function toReward( campaign: RewardCampaigns.Record, run: RewardRuns.DeliveredSummary | undefined, now: number, ): Reward | undefined { const boost = campaign.config.boostRewards if (!boost || boost.enabled === false || now >= boost.endTimestamp) return undefined return { asset: { address: campaign.earnShareAddress, chainId: campaign.chainId, decimals: campaign.earnShareDecimals, }, ...(run ? { calculatedThrough: Number(run.endsAt) } : {}), campaignId: campaign.vaultAddress, currentRewardsAprBps: latestBoostRate(run), endsAt: boost.endTimestamp, startsAt: boost.startTimestamp, targetTotalAprBps: boost.targetAnnualRateBps, } } function summarizeUnpaid( accounts: readonly Pick[], ): RewardAccounts.unpaid.Result { const deltas = accounts.map((account) => { const entitlement = BigInt(account.cumulativeEntitlement) const paid = BigInt(account.cumulativePaid) if (paid > entitlement) throw new Error('Onchain reward payment exceeds indexed entitlement.') return entitlement - paid }) return { earnShares: deltas.reduce((sum, delta) => sum + delta, 0n).toString(), recipients: deltas.filter((delta) => delta > 0n).length, } } function chunks(values: readonly Value[], size: number): Value[][] { const result: Value[][] = [] for (let index = 0; index < values.length; index += size) result.push(values.slice(index, index + size)) return result } const positionRatePrecision = 16 const positionRateScale = 10n ** BigInt(positionRatePrecision) function formatRatio(numerator: bigint, denominator: bigint, scale: bigint): string { const rounded = numerator >= 0n ? (numerator + denominator / 2n) / denominator : (numerator - denominator / 2n) / denominator const negative = rounded < 0n const absolute = negative ? -rounded : rounded const whole = absolute / scale const fraction = (absolute % scale).toString().padStart(positionRatePrecision, '0') const rendered = fraction.replace(/0+$/, '') return `${negative ? '-' : ''}${whole}${rendered ? `.${rendered}` : ''}` } function parseRatio(value: string): { scale: bigint; value: bigint } | undefined { const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(value) const fraction = match?.[3] ?? '' if (!match || fraction.length > positionRatePrecision) return undefined const scaled = BigInt(match[2]!) * positionRateScale + BigInt(fraction.padEnd(positionRatePrecision, '0')) return { scale: positionRateScale, value: match[1] === '-' ? -scaled : scaled } }