import { getAddress, keccak256, stringToHex, zeroAddress, type Address, type Hex } from 'viem' import type * as Campaigns from './Campaigns.js' const rateScale = 10_000n const secondsPerYear = 365n * 24n * 60n * 60n const sharePriceScale = 10n ** 18n /** Current mutable projection for one registered recipient. */ export type Account = { /** Arithmetic remainder carried across reward intervals. */ accrualRemainder: bigint /** Principal permanently allocated against campaign caps. */ allocatedPrincipalAssets: bigint /** Cumulative EarnShare entitlement. */ cumulativeEntitlement: bigint /** Cumulative EarnShare already paid. */ cumulativePaid: bigint /** Current delivery deferral. */ deferral?: string | undefined /** Eligibility registration version consumed by this projection. */ eligibilityRegisteredAt?: string | undefined /** Surviving qualified deposit lots. */ lots: Campaigns.DepositLot[] /** Base-asset reward not yet converted into EarnShare. */ pendingRewardAssets: bigint /** Current attributable public EarnShare balance. */ publicEarnShares: bigint /** Current qualified EarnShare balance. */ qualifiedEarnShares: bigint /** Registered recipient. */ recipient: Address /** Monotonic eligibility registration order. */ registrationOrder: bigint } /** One canonically ordered public deposit or EarnShare transfer. */ export type Event = | { /** Deposited base assets. */ assets: bigint /** Canonical event position. */ cursor: Campaigns.EventCursor /** EarnShare minted by the deposit. */ earnShares: bigint /** Deposit event kind. */ kind: 'deposit' /** Deposit receiver. */ recipient: Address /** Block timestamp. */ timestamp: number } | { /** Transferred EarnShare. */ amount: bigint /** Canonical event position. */ cursor: Campaigns.EventCursor /** Sender, or zero address for mint. */ from: Address /** Transfer event kind. */ kind: 'transfer' /** Block timestamp. */ timestamp: number /** Recipient, or zero address for burn. */ to: Address } /** Returns a deterministic checksum for a run's complete closing account state. */ export function checksum(accounts: readonly Account[]): Hex { return keccak256( stringToHex( JSON.stringify( [...accounts].sort(compareAccounts).map((account) => ({ accrualRemainder: account.accrualRemainder.toString(), allocatedPrincipalAssets: account.allocatedPrincipalAssets.toString(), cumulativeEntitlement: account.cumulativeEntitlement.toString(), cumulativePaid: account.cumulativePaid.toString(), deferral: account.deferral ?? null, eligibilityRegisteredAt: account.eligibilityRegisteredAt ?? null, lots: account.lots, pendingRewardAssets: account.pendingRewardAssets.toString(), publicEarnShares: account.publicEarnShares.toString(), qualifiedEarnShares: account.qualifiedEarnShares.toString(), recipient: account.recipient.toLowerCase(), registrationOrder: account.registrationOrder.toString(), })), ), ), ) } /** Restores one calculation projection from a durable run snapshot. */ export function fromSnapshot(snapshot: Campaigns.AccountSnapshot): Account { return { accrualRemainder: BigInt(snapshot.accrualRemainder), allocatedPrincipalAssets: BigInt(snapshot.allocatedPrincipalAssets), cumulativeEntitlement: BigInt(snapshot.cumulativeEntitlement), cumulativePaid: BigInt(snapshot.cumulativePaid), ...(snapshot.deferral ? { deferral: snapshot.deferral } : {}), ...(snapshot.eligibilityRegisteredAt ? { eligibilityRegisteredAt: snapshot.eligibilityRegisteredAt } : {}), lots: [], pendingRewardAssets: BigInt(snapshot.pendingRewardAssets), publicEarnShares: BigInt(snapshot.publicEarnShares), qualifiedEarnShares: BigInt(snapshot.qualifiedEarnShares), recipient: getAddress(snapshot.recipient), registrationOrder: BigInt(snapshot.registrationOrder), } } /** Serializes one calculation projection for crash-safe run recovery. */ export function snapshot(account: Account): Campaigns.AccountSnapshot { return { accrualRemainder: account.accrualRemainder.toString(), allocatedPrincipalAssets: account.allocatedPrincipalAssets.toString(), cumulativeEntitlement: account.cumulativeEntitlement.toString(), cumulativePaid: account.cumulativePaid.toString(), deferral: account.deferral ?? null, eligibilityRegisteredAt: account.eligibilityRegisteredAt ?? null, lots: [], pendingRewardAssets: account.pendingRewardAssets.toString(), publicEarnShares: account.publicEarnShares.toString(), qualifiedEarnShares: account.qualifiedEarnShares.toString(), recipient: account.recipient, registrationOrder: account.registrationOrder.toString(), } } /** Applies ordered public events and campaign cap allocation to registered accounts. */ export function apply(options: apply.Options): apply.Result { const result = applyEvents(options, send) return { ...result, accounts: result.accounts.map((account) => ({ ...account, lots: [] })), } } function applyEvents(options: apply.Options, sendAccount: typeof send): apply.Result { const accounts = new Map( options.accounts.map((account) => [account.recipient.toLowerCase(), clone(account)]), ) const eligible = new Map( options.eligible.map((entry) => [entry.recipient.toLowerCase(), entry.registrationOrder]), ) const excluded = new Set(options.excluded.map((address) => address.toLowerCase())) for (const [recipient] of eligible) account(accounts, eligible, excluded, getAddress(recipient)) let allocated = [...accounts.values()].reduce( (sum, account) => sum + account.allocatedPrincipalAssets, 0n, ) const events = [...options.events].sort(compareEvents) for (const event of events) { if (event.kind === 'transfer') { if (sameAddress(event.from, event.to)) continue if (event.from.toLowerCase() !== zeroAddress) { const sender = accounts.get(event.from.toLowerCase()) if (sender) sendAccount(sender, event.amount) } if (event.to.toLowerCase() !== zeroAddress) { const recipient = account(accounts, eligible, excluded, event.to) if (recipient) recipient.publicEarnShares += event.amount } continue } const recipient = account(accounts, eligible, excluded, event.recipient) if (!recipient || event.assets === 0n || event.earnShares === 0n) continue const userRemaining = maximum( options.perUserPrincipalCapAssets - recipient.allocatedPrincipalAssets, 0n, ) const campaignRemaining = maximum(options.totalPrincipalCapAssets - allocated, 0n) const allocatedAssets = minimum(event.assets, userRemaining, campaignRemaining) if (allocatedAssets === 0n) continue const qualifiedShares = (event.earnShares * allocatedAssets) / event.assets if (qualifiedShares === 0n) continue recipient.allocatedPrincipalAssets += allocatedAssets recipient.qualifiedEarnShares += qualifiedShares recipient.lots.push({ allocatedPrincipalAssets: allocatedAssets.toString(), depositedAssets: event.assets.toString(), depositedEarnShares: event.earnShares.toString(), event: event.cursor, remainingEarnShares: qualifiedShares.toString(), }) allocated += allocatedAssets } return { accounts: [...accounts.values()].sort(compareAccounts), allocatedPrincipalAssets: allocated, } } /** Reconstructs surviving pre-campaign deposit lots before permanently allocating campaign caps. */ export function bootstrap(options: bootstrap.Options): apply.Result { const unlimited = (1n << 256n) - 1n const replayed = applyEvents( { accounts: [], eligible: options.eligible, events: options.events, excluded: options.excluded, perUserPrincipalCapAssets: unlimited, totalPrincipalCapAssets: unlimited, }, sendLots, ) return allocateBootstrap(replayed.accounts, options) } function allocateBootstrap( replayed: readonly Account[], options: Omit, ): apply.Result { const accounts = replayed.map((source) => ({ ...clone(source), allocatedPrincipalAssets: 0n, lots: [] as Campaigns.DepositLot[], qualifiedEarnShares: 0n, })) const byRecipient = new Map(accounts.map((entry) => [entry.recipient.toLowerCase(), entry])) let allocated = 0n const lots = replayed .flatMap((entry) => entry.lots.map((lot) => ({ lot, recipient: entry.recipient }))) .sort((left, right) => { if (options.principalAllocationOrder === 'registration') { const order = byRecipient.get(left.recipient.toLowerCase())!.registrationOrder - byRecipient.get(right.recipient.toLowerCase())!.registrationOrder if (order !== 0n) return order < 0n ? -1 : 1 } return compareCursors(left.lot.event, right.lot.event) }) for (const { lot, recipient } of lots) { const account = byRecipient.get(recipient.toLowerCase())! const remainingEarnShares = BigInt(lot.remainingEarnShares) const depositedEarnShares = BigInt(lot.depositedEarnShares) const survivingPrincipalAssets = depositedEarnShares === 0n ? 0n : (BigInt(lot.depositedAssets) * remainingEarnShares) / depositedEarnShares const userRemaining = maximum( options.perUserPrincipalCapAssets - account.allocatedPrincipalAssets, 0n, ) const campaignRemaining = maximum(options.totalPrincipalCapAssets - allocated, 0n) const allocatedPrincipalAssets = minimum( survivingPrincipalAssets, userRemaining, campaignRemaining, ) if (allocatedPrincipalAssets === 0n || survivingPrincipalAssets === 0n) continue const qualifiedEarnShares = (remainingEarnShares * allocatedPrincipalAssets) / survivingPrincipalAssets if (qualifiedEarnShares === 0n) continue account.allocatedPrincipalAssets += allocatedPrincipalAssets account.qualifiedEarnShares += qualifiedEarnShares allocated += allocatedPrincipalAssets } return { accounts: accounts.sort(compareAccounts), allocatedPrincipalAssets: allocated } } /** Reconciles registration history pagewise and checkpoints replay state without retaining events. */ export async function reconcilePages( options: reconcilePages.Options, ): Promise { const current = new Map( options.accounts.map((account) => [account.recipient.toLowerCase(), account]), ) const pending = new Map( options.eligible .filter((entry) => { const account = current.get(entry.recipient.toLowerCase()) return account?.eligibilityRegisteredAt !== entry.registeredAt }) .map((entry) => [entry.recipient.toLowerCase(), entry]), ) if (pending.size === 0) return { accounts: options.accounts, reconciled: false } const replay = new Map( [...pending].filter(([recipient]) => { const account = current.get(recipient) return ( !account || (account.allocatedPrincipalAssets === 0n && account.lots.length === 0 && account.qualifiedEarnShares === 0n) ) }), ) const retained = options.accounts.filter( (account) => !replay.has(account.recipient.toLowerCase()), ) const retainedAllocated = retained.reduce( (sum, account) => sum + account.allocatedPrincipalAssets, 0n, ) let replayed = options.checkpoint ? options.checkpoint.accounts.map(fromSnapshot) : apply({ accounts: [], eligible: [...replay.values()].map((entry) => ({ recipient: entry.recipient, registrationOrder: entry.registrationOrder, })), events: [], excluded: options.excluded, perUserPrincipalCapAssets: 0n, totalPrincipalCapAssets: 0n, }).accounts const depositedEarnShares = new Map( Object.entries(options.checkpoint?.depositedEarnShares ?? {}).map(([recipient, value]) => [ recipient, BigInt(value), ]), ) let replayedThrough = options.checkpoint?.replayedThrough if (!options.checkpoint || options.checkpoint.pass === 'survival') for await (const page of options.pages('survival', options.checkpoint?.cursor)) { const { events } = page const accounts = new Map(replayed.map((entry) => [entry.recipient.toLowerCase(), entry])) const eligible = new Map( [...replay].map(([recipient, entry]) => [recipient, entry.registrationOrder]), ) const excluded = new Set(options.excluded.map((address) => address.toLowerCase())) for (const event of [...events].sort(compareEvents)) { if (event.kind === 'deposit') { const recipient = account(accounts, eligible, excluded, event.recipient) if (!recipient || event.assets === 0n || event.earnShares === 0n) continue const key = recipient.recipient.toLowerCase() depositedEarnShares.set(key, (depositedEarnShares.get(key) ?? 0n) + event.earnShares) recipient.qualifiedEarnShares += event.earnShares continue } if (sameAddress(event.from, event.to)) continue if (event.from.toLowerCase() !== zeroAddress) { const sender = accounts.get(event.from.toLowerCase()) if (sender) send(sender, event.amount) } if (event.to.toLowerCase() !== zeroAddress) { const recipient = account(accounts, eligible, excluded, event.to) if (recipient) recipient.publicEarnShares += event.amount } } replayed = [...accounts.values()].sort(compareAccounts) const cursor = events.at(-1)?.cursor if (cursor) { replayedThrough = cursor await options.checkpointState?.({ accounts: replayed.map(snapshot), cursor, depositedEarnShares: Object.fromEntries( [...depositedEarnShares].map(([recipient, value]) => [recipient, value.toString()]), ), pass: 'survival', replayedThrough, }) } } let initialized = options.checkpoint?.pass === 'allocation' ? options.checkpoint.accounts.map(fromSnapshot) : replayed.map((entry) => ({ ...entry, allocatedPrincipalAssets: 0n, lots: [], qualifiedEarnShares: 0n, })) const consumedEarnShares = new Map( options.checkpoint?.pass === 'allocation' ? Object.entries(options.checkpoint.consumedEarnShares ?? {}).map(([recipient, value]) => [ recipient, BigInt(value), ]) : replayed.map((entry) => [ entry.recipient.toLowerCase(), (depositedEarnShares.get(entry.recipient.toLowerCase()) ?? 0n) - entry.qualifiedEarnShares, ]), ) let allocated = retainedAllocated + initialized.reduce((sum, entry) => sum + entry.allocatedPrincipalAssets, 0n) for await (const page of options.pages( 'allocation', options.checkpoint?.pass === 'allocation' ? options.checkpoint.cursor : undefined, )) { const { events } = page const accounts = new Map(initialized.map((entry) => [entry.recipient.toLowerCase(), entry])) for (const event of events) { if (event.kind !== 'deposit') continue const recipient = accounts.get(event.recipient.toLowerCase()) if (!recipient || event.assets === 0n || event.earnShares === 0n) continue const key = recipient.recipient.toLowerCase() const consumed = minimum(consumedEarnShares.get(key) ?? 0n, event.earnShares) consumedEarnShares.set(key, (consumedEarnShares.get(key) ?? 0n) - consumed) const remainingEarnShares = event.earnShares - consumed const survivingPrincipalAssets = (event.assets * remainingEarnShares) / event.earnShares const allocatedPrincipalAssets = minimum( survivingPrincipalAssets, maximum(options.perUserPrincipalCapAssets - recipient.allocatedPrincipalAssets, 0n), maximum(options.totalPrincipalCapAssets - allocated, 0n), ) if (allocatedPrincipalAssets === 0n || survivingPrincipalAssets === 0n) continue const qualifiedEarnShares = (remainingEarnShares * allocatedPrincipalAssets) / survivingPrincipalAssets if (qualifiedEarnShares === 0n) continue recipient.allocatedPrincipalAssets += allocatedPrincipalAssets recipient.qualifiedEarnShares += qualifiedEarnShares allocated += allocatedPrincipalAssets } initialized = [...accounts.values()].sort(compareAccounts) const cursor = events.at(-1)?.cursor if (cursor || page.allocationRecipient) await options.checkpointState?.({ ...(page.allocationRecipient ? { allocationRecipient: page.allocationRecipient } : {}), accounts: initialized.map(snapshot), consumedEarnShares: Object.fromEntries( [...consumedEarnShares].map(([recipient, value]) => [recipient, value.toString()]), ), ...(cursor ? { cursor } : {}), depositedEarnShares: Object.fromEntries( [...depositedEarnShares].map(([recipient, value]) => [recipient, value.toString()]), ), pass: 'allocation', ...(replayedThrough ? { replayedThrough } : {}), }) } return { accounts: [ ...retained.map((account) => { const entry = pending.get(account.recipient.toLowerCase()) return entry ? { ...account, eligibilityRegisteredAt: entry.registeredAt } : account }), ...initialized.map((account) => { const entry = pending.get(account.recipient.toLowerCase())! const previous = current.get(account.recipient.toLowerCase()) return { ...account, accrualRemainder: previous?.accrualRemainder ?? 0n, cumulativeEntitlement: previous?.cumulativeEntitlement ?? 0n, cumulativePaid: previous?.cumulativePaid ?? 0n, ...(previous?.deferral ? { deferral: previous.deferral } : {}), eligibilityRegisteredAt: entry.registeredAt, pendingRewardAssets: previous?.pendingRewardAssets ?? 0n, } }), ].sort(compareAccounts), reconciled: true, } } export declare namespace reconcilePages { /** One historical page with optional registration traversal progress. */ type Page = { /** Registration-order recipient represented by this page. */ allocationRecipient?: Address | undefined /** Historical events applied by this page. */ events: readonly Event[] } /** Serializable historical replay state. */ type Checkpoint = { /** Recipient whose registration-ordered deposit page was last applied. */ allocationRecipient?: Address | undefined /** Replayed account projections before campaign cap allocation. */ accounts: readonly Campaigns.AccountSnapshot[] /** Remaining FIFO-consumed shares while allocating surviving deposits. */ consumedEarnShares?: Readonly> | undefined /** Final event included in this pass. */ cursor?: Campaigns.EventCursor | undefined /** Gross deposited shares keyed by normalized recipient. */ depositedEarnShares: Readonly> /** Historical reducer pass. */ pass: 'allocation' | 'survival' /** Final canonical event applied by the survival pass. */ replayedThrough?: Campaigns.EventCursor | undefined } /** Inputs for registration reconciliation over canonical event pages. */ type Options = Omit & { /** Previously persisted replay state. */ checkpoint?: Checkpoint | undefined /** Persists replay state after one complete canonical page. */ checkpointState?: ((state: Checkpoint) => Promise | void) | undefined /** Recreates historical pages in the ordering required by each pass. */ pages: (pass: Checkpoint['pass'], after?: Campaigns.EventCursor) => AsyncIterable } } /** Rebuilds deposit-derived state for new or refreshed eligibility registrations. */ export function reconcileRegistrations( options: reconcileRegistrations.Options, ): reconcileRegistrations.Result { const accounts = new Map( options.accounts.map((account) => [account.recipient.toLowerCase(), account]), ) const pending = new Map( options.eligible .filter((entry) => { const account = accounts.get(entry.recipient.toLowerCase()) return account?.eligibilityRegisteredAt !== entry.registeredAt }) .map((entry) => [entry.recipient.toLowerCase(), entry]), ) if (pending.size === 0) return { accounts: options.accounts, reconciled: false } // Principal allocation is permanent. Refresh only zero-state accounts so replays cannot reorder cap already committed to healthy accounts. const replay = new Map( [...pending].filter(([recipient]) => { const account = accounts.get(recipient) return ( !account || (account.allocatedPrincipalAssets === 0n && account.lots.length === 0 && account.qualifiedEarnShares === 0n) ) }), ) const retained = options.accounts.filter( (account) => !replay.has(account.recipient.toLowerCase()), ) const remainingCap = options.totalPrincipalCapAssets - retained.reduce((sum, account) => sum + account.allocatedPrincipalAssets, 0n) const initialized = bootstrap({ eligible: [...replay.values()].map((entry) => ({ recipient: entry.recipient, registrationOrder: entry.registrationOrder, })), events: options.events, excluded: options.excluded, perUserPrincipalCapAssets: options.perUserPrincipalCapAssets, principalAllocationOrder: options.principalAllocationOrder, totalPrincipalCapAssets: remainingCap, }) return { accounts: [ ...retained.map((account) => { const entry = pending.get(account.recipient.toLowerCase()) return entry ? { ...account, eligibilityRegisteredAt: entry.registeredAt } : account }), ...initialized.accounts.map((account) => { const entry = pending.get(account.recipient.toLowerCase())! const previous = accounts.get(account.recipient.toLowerCase()) return { ...account, accrualRemainder: previous?.accrualRemainder ?? 0n, cumulativeEntitlement: previous?.cumulativeEntitlement ?? 0n, cumulativePaid: previous?.cumulativePaid ?? 0n, ...(previous?.deferral ? { deferral: previous.deferral } : {}), eligibilityRegisteredAt: entry.registeredAt, pendingRewardAssets: previous?.pendingRewardAssets ?? 0n, } }), ].sort(compareAccounts), reconciled: true, } } export declare namespace reconcileRegistrations { /** Inputs for registration-driven historical reconciliation. */ type Options = Omit & { /** Current delivered account projections. */ accounts: readonly Account[] /** Eligibility registrations and their current durable versions. */ eligible: readonly { /** Eligible reward recipient. */ recipient: Address /** Monotonic eligibility registration order. */ registrationOrder: bigint /** Registration version returned by the eligibility API. */ registeredAt: string }[] } /** Reconciled accounts and whether any registration required replay. */ type Result = { /** Current accounts with refreshed deposit-derived state. */ accounts: readonly Account[] /** Whether at least one registration required reconciliation. */ reconciled: boolean } } export declare namespace bootstrap { /** Historical replay and cap-allocation input. */ type Options = Omit & { /** Deposit order by default; registration order preserves late-registration cap priority. */ principalAllocationOrder?: 'deposit' | 'registration' | undefined } } export declare namespace apply { /** Projection input at one persisted cursor. */ type Options = { /** Previously delivered account states. */ accounts: readonly Account[] /** Registered recipients with stable order. */ eligible: readonly { recipient: Address; registrationOrder: bigint }[] /** Canonically ordered public events. */ events: readonly Event[] /** Addresses excluded from individual boost rewards. */ excluded: readonly Address[] /** Per-recipient lifetime principal cap. */ perUserPrincipalCapAssets: bigint /** Campaign lifetime principal cap. */ totalPrincipalCapAssets: bigint } /** Closing projection and permanently allocated campaign principal. */ type Result = { /** Closing recipient projections. */ accounts: readonly Account[] /** Total principal permanently allocated against the campaign cap. */ allocatedPrincipalAssets: bigint } } /** Calculates time-weighted boost rewards for one interval. */ export function calculate(options: calculate.Options): calculate.Result { if (options.endsAt <= options.startsAt) throw new Error('reward interval is empty') const accounts = new Map( options.accounts.map((account) => [account.recipient.toLowerCase(), clone(account)]), ) const eligible = new Map( options.accounts.map((account) => [account.recipient.toLowerCase(), account.registrationOrder]), ) const excluded = new Set(options.excluded.map((address) => address.toLowerCase())) const weighted = new Map() const accruedThrough = new Map() let allocated = [...accounts.values()].reduce( (sum, account) => sum + account.allocatedPrincipalAssets, 0n, ) const events = [...options.events] .filter((event) => event.timestamp >= options.startsAt && event.timestamp < options.endsAt) .sort(compareEvents) for (const event of events) { for (const recipient of eventRecipients(event)) { const account = accounts.get(recipient.toLowerCase()) if (account) accumulateAccount( weighted, accruedThrough, account, excluded, options.startsAt, event.timestamp, ) } allocated = applyEvent(accounts, eligible, excluded, event, { allocated, perUserPrincipalCapAssets: options.perUserPrincipalCapAssets, totalPrincipalCapAssets: options.totalPrincipalCapAssets, }) } for (const account of accounts.values()) accumulateAccount(weighted, accruedThrough, account, excluded, options.startsAt, options.endsAt) const rewards = new Map() const denominator = sharePriceScale * rateScale * secondsPerYear for (const account of accounts.values()) { const shareSeconds = weighted.get(account.recipient.toLowerCase()) ?? 0n const numerator = shareSeconds * options.assetPerEarnShareWad * BigInt(options.annualRateBps) + account.accrualRemainder const amount = numerator / denominator account.accrualRemainder = numerator % denominator account.pendingRewardAssets += amount if (amount > 0n) rewards.set(account.recipient, amount) } return { accounts: [...accounts.values()].sort(compareAccounts), rewards } } export declare namespace calculate { /** Time-weighted reward calculation input. */ type Options = { /** Opening base assets per EarnShare, scaled by 1e18. */ assetPerEarnShareWad: bigint /** Opening recipient projections. */ accounts: readonly Account[] /** Incremental boost annual rate in basis points. */ annualRateBps: number /** Exclusive interval end timestamp. */ endsAt: number /** Public events within the interval. */ events: readonly Event[] /** Addresses excluded from individual rewards. */ excluded: readonly Address[] /** Per-recipient lifetime principal cap. */ perUserPrincipalCapAssets: bigint /** Inclusive interval start timestamp. */ startsAt: number /** Campaign lifetime principal cap. */ totalPrincipalCapAssets: bigint } /** Closing projections and newly accrued base-asset rewards. */ type Result = { /** Closing recipient projections. */ accounts: readonly Account[] /** Newly accrued base-asset rewards by recipient. */ rewards: ReadonlyMap } } /** Calculates one reward interval pagewise and checkpoints exact arithmetic state. */ export async function calculatePages(options: calculatePages.Options): Promise { if (options.endsAt <= options.startsAt) throw new Error('reward interval is empty') const accounts = new Map( (options.checkpoint?.accounts.map(fromSnapshot) ?? options.accounts.map(clone)).map( (account) => [account.recipient.toLowerCase(), account], ), ) const eligible = new Map( options.accounts.map((account) => [account.recipient.toLowerCase(), account.registrationOrder]), ) const excluded = new Set(options.excluded.map((address) => address.toLowerCase())) const accruedThrough = new Map( Object.entries(options.checkpoint?.accruedThrough ?? {}), ) const weighted = new Map( Object.entries(options.checkpoint?.weightedEarnShareSeconds ?? {}).map(([recipient, value]) => [ recipient, BigInt(value), ]), ) let allocated = [...accounts.values()].reduce( (sum, account) => sum + account.allocatedPrincipalAssets, 0n, ) for await (const page of options.pages) { const events = [...page] .filter((event) => event.timestamp >= options.startsAt && event.timestamp < options.endsAt) .sort(compareEvents) for (const event of events) { for (const recipient of eventRecipients(event)) { const account = accounts.get(recipient.toLowerCase()) if (account) accumulateAccount( weighted, accruedThrough, account, excluded, options.startsAt, event.timestamp, ) } allocated = applyEvent(accounts, eligible, excluded, event, { allocated, perUserPrincipalCapAssets: options.perUserPrincipalCapAssets, totalPrincipalCapAssets: options.totalPrincipalCapAssets, }) } const cursor = page.at(-1)?.cursor if (cursor) await options.checkpointState?.( { accounts: [...accounts.values()].sort(compareAccounts).map(snapshot), accruedThrough: Object.fromEntries(accruedThrough), weightedEarnShareSeconds: Object.fromEntries( [...weighted].map(([recipient, value]) => [recipient, value.toString()]), ), }, cursor, ) } for (const account of accounts.values()) accumulateAccount(weighted, accruedThrough, account, excluded, options.startsAt, options.endsAt) const rewards = new Map() const denominator = sharePriceScale * rateScale * secondsPerYear for (const account of accounts.values()) { const shareSeconds = weighted.get(account.recipient.toLowerCase()) ?? 0n const numerator = shareSeconds * options.assetPerEarnShareWad * BigInt(options.annualRateBps) + account.accrualRemainder const amount = numerator / denominator account.accrualRemainder = numerator % denominator account.pendingRewardAssets += amount if (amount > 0n) rewards.set(account.recipient, amount) } return { accounts: [...accounts.values()].sort(compareAccounts), rewards } } export declare namespace calculatePages { /** Serializable interval reducer state before reward finalization. */ type Checkpoint = { /** Account projections after the checkpointed page. */ accounts: readonly Campaigns.AccountSnapshot[] /** Last accrued timestamp keyed by normalized recipient. */ accruedThrough: Readonly> /** Weighted EarnShare seconds keyed by normalized recipient. */ weightedEarnShareSeconds: Readonly> } /** Inputs for reward calculation over canonical event pages. */ type Options = Omit & { /** Previously persisted interval reducer state. */ checkpoint?: Checkpoint | undefined /** Persists reducer state after one complete canonical page. */ checkpointState?: | ((state: Checkpoint, cursor: Campaigns.EventCursor) => Promise | void) | undefined /** Canonical event pages beginning after the persisted cursor. */ pages: AsyncIterable } } /** Converts a database account into the arithmetic projection. */ export function fromRecord(record: fromRecord.Record): Account { return { accrualRemainder: BigInt(record.accrualRemainder), allocatedPrincipalAssets: BigInt(record.allocatedPrincipalAssets), cumulativeEntitlement: BigInt(record.cumulativeEntitlement), cumulativePaid: BigInt(record.cumulativePaid), ...(record.deferral ? { deferral: record.deferral } : {}), ...(record.eligibilityRegisteredAt ? { eligibilityRegisteredAt: record.eligibilityRegisteredAt } : {}), lots: [], pendingRewardAssets: BigInt(record.pendingRewardAssets), publicEarnShares: BigInt(record.publicEarnShares), qualifiedEarnShares: BigInt(record.qualifiedEarnShares), recipient: getAddress(record.recipient), registrationOrder: BigInt(record.registrationOrder), } } export declare namespace fromRecord { /** Persisted account fields consumed by the projection. */ type Record = { accrualRemainder: string allocatedPrincipalAssets: string cumulativeEntitlement: string cumulativePaid: string deferral: string | null eligibilityRegisteredAt: string | null lots: readonly Campaigns.DepositLot[] pendingRewardAssets: string publicEarnShares: string qualifiedEarnShares: string recipient: Address registrationOrder: string } } function account( accounts: Map, eligible: ReadonlyMap, excluded: ReadonlySet, address: Address, ): Account | undefined { const key = address.toLowerCase() if (excluded.has(key)) return undefined const order = eligible.get(key) if (order === undefined) return undefined const existing = accounts.get(key) if (existing) return existing const created: Account = { accrualRemainder: 0n, allocatedPrincipalAssets: 0n, cumulativeEntitlement: 0n, cumulativePaid: 0n, lots: [], pendingRewardAssets: 0n, publicEarnShares: 0n, qualifiedEarnShares: 0n, recipient: getAddress(address), registrationOrder: order, } accounts.set(key, created) return created } function accumulateAccount( weighted: Map, accruedThrough: Map, account: Account, excluded: ReadonlySet, startsAt: number, through: number, ) { const key = account.recipient.toLowerCase() const previous = accruedThrough.get(key) ?? startsAt const seconds = through - previous if (seconds < 0) throw new Error('reward events are not time ordered') if (!excluded.has(key)) { const balance = minimum(account.publicEarnShares, account.qualifiedEarnShares) weighted.set(key, (weighted.get(key) ?? 0n) + balance * BigInt(seconds)) } accruedThrough.set(key, through) } function eventRecipients(event: Event): readonly Address[] { if (event.kind === 'deposit') return [event.recipient] if (event.from.toLowerCase() === event.to.toLowerCase()) return [event.from] return [event.from, event.to] } function applyEvent( accounts: Map, eligible: ReadonlyMap, excluded: ReadonlySet, event: Event, caps: { allocated: bigint perUserPrincipalCapAssets: bigint totalPrincipalCapAssets: bigint }, ): bigint { if (event.kind === 'deposit') { const recipient = account(accounts, eligible, excluded, event.recipient) if (!recipient || event.assets === 0n || event.earnShares === 0n) return caps.allocated const allocatedAssets = minimum( event.assets, maximum(caps.perUserPrincipalCapAssets - recipient.allocatedPrincipalAssets, 0n), maximum(caps.totalPrincipalCapAssets - caps.allocated, 0n), ) if (allocatedAssets === 0n) return caps.allocated const qualifiedShares = (event.earnShares * allocatedAssets) / event.assets if (qualifiedShares === 0n) return caps.allocated recipient.allocatedPrincipalAssets += allocatedAssets recipient.qualifiedEarnShares += qualifiedShares return caps.allocated + allocatedAssets } if (sameAddress(event.from, event.to)) return caps.allocated if (event.from.toLowerCase() !== zeroAddress) { const sender = accounts.get(event.from.toLowerCase()) if (sender) send(sender, event.amount) } if (event.to.toLowerCase() !== zeroAddress) { const recipient = account(accounts, eligible, excluded, event.to) if (recipient) recipient.publicEarnShares += event.amount } return caps.allocated } function send(account: Account, amount: bigint): void { account.publicEarnShares = maximum(account.publicEarnShares - amount, 0n) account.qualifiedEarnShares = maximum(account.qualifiedEarnShares - amount, 0n) } function sendLots(account: Account, amount: bigint): void { account.publicEarnShares = maximum(account.publicEarnShares - amount, 0n) let remaining = amount for (const lot of account.lots) { if (remaining === 0n) break const shares = BigInt(lot.remainingEarnShares) const consumed = minimum(shares, remaining) lot.remainingEarnShares = (shares - consumed).toString() account.qualifiedEarnShares -= consumed remaining -= consumed } } function sameAddress(left: Address, right: Address): boolean { return left.toLowerCase() === right.toLowerCase() } function clone(account: Account): Account { return { ...account, lots: account.lots.map((lot) => ({ ...lot, event: { ...lot.event } })) } } function compareAccounts(left: Account, right: Account): number { return left.registrationOrder < right.registrationOrder ? -1 : left.registrationOrder > right.registrationOrder ? 1 : left.recipient.toLowerCase().localeCompare(right.recipient.toLowerCase()) } function compareEvents(left: Event, right: Event): number { return compareCursors(left.cursor, right.cursor) } function compareCursors(left: Campaigns.EventCursor, right: Campaigns.EventCursor): number { return ( left.blockNumber - right.blockNumber || left.transactionIndex - right.transactionIndex || left.logIndex - right.logIndex ) } function maximum(left: bigint, right: bigint): bigint { return left > right ? left : right } function minimum(...values: readonly bigint[]): bigint { return values.reduce((result, value) => (value < result ? value : result)) }