import { erc20Abi, getAddress, isAddress, isAddressEqual, parseAbi, parseEventLogs, type Address, type Hex, } from 'viem' 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 * as RewardRuns from '../../db/tables/rewardRuns.js' import * as RewardTransactionAttempts from '../../db/tables/rewardTransactionAttempts.js' import * as EarnVaultRecords from '../../db/tables/earnVaults.js' import type * as Db from '../../db/Db.js' import * as Campaigns from './Campaigns.js' import * as Events from './Events.js' import * as Projection from './Projection.js' import * as Rates from './Rates.js' import * as Signer from './Signer.js' import type * as Tidx from '../Tidx.js' import * as Viem from '../Viem.js' const claimDeadline = Number((1n << 40n) - 1n) const maximumIntervalsPerRun = 12 // Each wave produces at most 64 historical multicalls per RPC round. const capitalStateBatchSize = 64 // Keep enough headroom below the Worker's 128 MiB memory limit for account projection, // the cumulative Merkle tree, and payout construction to coexist in one invocation. const maximumRecipients = 10_000 // The Earn operator's Tempo gas model budgets 200k per transfer and 250k per // push call. Two 60-recipient calls stay below 90% of the signer's 28M cap. const payoutBatchSize = 60 const payoutCallsPerTransaction = 2 const payoutTransactionConcurrency = 16 const runLeaseMilliseconds = 2 * 60 * 1_000 const factoryAbi = parseAbi([ 'function predictContributionController(address earnVault,address owner) view returns (address)', 'function predictMerkleDistributor(address earnVault,address owner,address treasury,uint40 claimDeadline) view returns (address)', ]) const vaultAbi = parseAbi([ 'function earnFees() view returns (address)', 'function engine() view returns (address)', 'function previewRedeem(uint256 earnShares) view returns (uint256)', 'function totalAssets() view returns (uint256)', 'function totalEarnShares() view returns (uint256)', ]) const feesAbi = parseAbi([ 'function previewAccruedFees() view returns ((uint256 activeAssets,uint256 positiveAccrualAssets,uint256 fixedFeeAssets,uint256 excessFeeAssets,uint256 totalFeeAssets,uint256 totalFeeEarnShares,uint256 preFeeValuePerEarnShare,uint256 postFeeValuePerEarnShare,uint256 targetValuePerEarnShare,uint8 allocationCount,(address account,uint256 feeAssets,uint256 feeEarnShares)[5] allocations) result)', ]) const controllerAbi = parseAbi([ 'event Funded(address indexed funder,uint256 requestedAssets,uint256 fundedAssets)', ]) const distributorAbi = parseAbi([ 'function cumulativePaid(address recipient) view returns (uint256)', 'function merkleRoot() view returns (bytes32)', 'function rootTotalEntitlement() view returns (uint256)', 'function rootVersion() view returns (uint64)', 'function statementHash() view returns (bytes32)', 'function totalPaid() view returns (uint256)', ]) /** Compact queue reference for one Earn reward campaign. */ export type Message = { /** Tempo chain containing the campaign. */ chainId: number /** Queue message discriminator. */ type: 'earn:reward:run' /** Verified EarnVault address. */ vaultAddress: Address } /** Lists due campaigns as compact queue references. */ export async function due(db: Db.Db, now = Date.now()): Promise { const campaigns: RewardCampaigns.Record[] = [] const pageSize = 100 for (let offset = 0; ; offset += pageSize) { const page = await RewardCampaigns.listDue(db, { limit: pageSize, offset, through: Math.floor(now / 1_000), }) campaigns.push(...page) if (page.length < pageSize) break } for (let offset = 0; ; offset += pageSize) { const page = await RewardCampaigns.listUnprovisioned(db, { limit: pageSize, offset }) campaigns.push(...page) if (page.length < pageSize) break } return [ ...new Map( campaigns.map((campaign) => [`${campaign.chainId}:${campaign.vaultAddress}`, campaign]), ).values(), ] .filter( (campaign) => Number(campaign.deliveredThrough) < Campaigns.finalBoundary(campaign.config) || [campaign.config, campaign.pendingConfig].some( (config) => Boolean(config?.targetYield && !campaign.controllerAddress) || Boolean(config?.boostRewards && !campaign.distributorAddress), ), ) .map((campaign) => ({ chainId: campaign.chainId, type: 'earn:reward:run', vaultAddress: getAddress(campaign.vaultAddress), })) } /** Selects resumable state only when its eligibility snapshot is unchanged. */ export function resumeCheckpoint( checkpoint: Campaigns.RunCheckpointEvidence | undefined, options: resumeCheckpoint.Options, ): Campaigns.RunCheckpointEvidence | undefined { return checkpoint?.progress.eligibilityVersion === options.eligibilityVersion && checkpoint.progress.exclusionVersion === options.exclusionVersion ? checkpoint : undefined } export declare namespace resumeCheckpoint { /** Current calculation identity. */ type Options = { /** Exact current eligibility snapshot identity. */ eligibilityVersion: Hex /** Exact current effective exclusion set identity. */ exclusionVersion: Hex } } /** Keeps the committed campaign cursor ahead of replay-only history. */ export function reconciledCursor(options: reconciledCursor.Options): Campaigns.EventCursor | null { return options.committed ?? options.replayed ?? null } export declare namespace reconciledCursor { /** Historical and committed cursor candidates. */ type Options = { /** Cursor already committed by the campaign. */ committed?: Campaigns.EventCursor | null | undefined /** Last event observed while replaying a new registration. */ replayed?: Campaigns.EventCursor | undefined } } /** Restores the target rate selected before a calculation checkpoint. */ export function checkpointTargetRate( checkpoint: Campaigns.RunCheckpointEvidence | undefined, config: NonNullable, ) { const progress = checkpoint?.progress if (progress?.targetRateBps === undefined || !progress.targetRateObservedAt) return undefined return { annualRateBps: progress.targetRateBps, config, observedAt: progress.targetRateObservedAt, ...(progress.staleRates?.includes('target') ? { stale: true } : {}), } } /** Advances one campaign through its durable, fenced execution phases. */ export async function run(options: run.Options): Promise { const campaign = await RewardCampaigns.get(options.db, options.message) if (!campaign || campaign.paused) return { status: 'idle' } if (campaign.chainId !== Viem.chainId.mainnet) throw new Error('Reward execution is currently supported only on Tempo mainnet.') const { bindings, signer } = await (async () => { try { const signer = await Signer.health(options.signerFetch) const bindings = await provision(options, campaign, signer.signer) return { bindings, signer } } catch (cause) { await RewardCampaigns.setProvisioningError(options.db, { chainId: campaign.chainId, config: provisioningPlan(campaign).configMatch, error: cause instanceof Error ? cause.message : 'Reward provisioning failed.', vaultAddress: getAddress(campaign.vaultAddress), }) throw cause } })() const start = Number(campaign.deliveredThrough) const scheduleEnd = Campaigns.finalBoundary(campaign.config) const intervalSeconds = Campaigns.intervalSeconds(campaign.config) const clock = Math.floor(options.now() / 1_000) const completeIntervals = Math.max( 0, Math.floor((Math.min(clock, scheduleEnd) - start) / intervalSeconds), ) let end = start + Math.min(completeIntervals, maximumIntervalsPerRun) * intervalSeconds const nextScheduleStart = [ campaign.config.targetYield?.startTimestamp, campaign.config.boostRewards?.startTimestamp, ] .filter((timestamp): timestamp is number => timestamp !== undefined && timestamp > start) .sort((left, right) => left - right)[0] if (nextScheduleStart !== undefined) end = Math.min(end, nextScheduleStart) if (campaign.pendingEffectiveAt !== null) end = Math.min(end, Number(campaign.pendingEffectiveAt)) if (end <= start) return { status: 'idle' } const ensured = await RewardRuns.ensure(options.db, { chainId: campaign.chainId, config: campaign.config, endsAt: end, startsAfter: start, vaultAddress: getAddress(campaign.vaultAddress), }) const leased = await RewardRuns.acquire(options.db, { id: ensured.id, leaseMilliseconds: runLeaseMilliseconds, }) if (!leased) return { runId: ensured.id, status: 'busy' } const heartbeat = maintainLease(options.db, leased, options.now) const executionOptions = { ...options, assertLease: () => heartbeat.assert() } try { await heartbeat.ready() if (leased.phase === 'paying' && leased.statement && leased.evidence) { await deliver( executionOptions, campaign, leased, bindings, leased.statement, restoreCalculation(leased.evidence), signer.signer, ) const result = { notification: await notificationSummary(options.db, campaign, leased.id), runId: leased.id, staleRates: leased.evidence.staleRates ?? [], status: 'delivered' as const, } const leaseFailure = await heartbeat.stop() if (leaseFailure !== undefined) throw leaseFailure return result } if (!['indexing', 'calculating', 'targetYield', 'statement'].includes(leased.phase)) throw new Error(`Unsupported reward recovery phase ${leased.phase}.`) const checkpoint = await RewardRuns.getCheckpoint(options.db, { id: leased.id }) const calculated = leased.evidence && !checkpoint ? restoreCalculation(leased.evidence) : await calculate(executionOptions, campaign, leased, bindings) const targetAttempt = leased.phase === 'statement' ? undefined : await settleTargetYield( executionOptions, campaign, leased, bindings, calculated, signer.signer, ) const settlement = await settleBoost( executionOptions, campaign, leased, bindings, calculated, signer.signer, ) // The leased record and initial calculation predate settlement, so delivery consumes the evidence returned by settlement. await deliver( executionOptions, campaign, leased, bindings, settlement?.statement, settlement ? { ...calculated, evidence: settlement.evidence } : calculated, signer.signer, ) const result = { notification: await notificationSummary(options.db, campaign, leased.id), runId: leased.id, staleRates: calculated.evidence.staleRates ?? [], status: 'delivered' as const, targetAttempt, } const leaseFailure = await heartbeat.stop() if (leaseFailure !== undefined) throw leaseFailure return result } catch (cause) { await heartbeat.stop() await RewardRuns.releaseWithError(options.db, { error: cause instanceof Error ? cause.message : 'Reward run failed.', fence: leased.fence, id: leased.id, }) throw cause } } export declare namespace run { /** Runtime dependencies injected by the API Worker. */ type Options = { /** Authoritative Postgres connection. */ db: Db.Db /** Reviewed rewards factory. */ factory: Address /** Chain-aware RPC client factory. */ getClient: Viem.GetClient /** Chain-aware TIDX client factory. */ getTidx: Tidx.GetClient /** HTTP implementation for external rate sources. */ fetch: typeof globalThis.fetch /** Compact queue reference. */ message: Message /** Clock used to determine closed interval boundaries. */ now: () => number /** Isolated signer service transport. */ signerFetch: typeof globalThis.fetch /** Internal lease assertion installed after a run is acquired. */ assertLease?: (() => Promise) | undefined /** Hosted Zone metadata used to exclude infrastructure custody. */ zones: readonly { contracts?: Record | undefined sourceId?: number | undefined }[] } /** Observable queue outcome. */ type Result = { /** Confirmed run details used by operational notifications. */ notification?: Notification | undefined /** Durable run identity when work existed. */ runId?: string | undefined /** Whether another worker owns the lease, no work was due, or delivery completed. */ status: 'busy' | 'delivered' | 'idle' /** Rate sources that reused a bounded prior observation. */ staleRates?: readonly ('organic' | 'target')[] | undefined /** Confirmed target-yield transaction, when one was needed. */ targetAttempt?: Hex | undefined } /** Concise confirmed run details used by operational notifications. */ type Notification = { /** Base-asset decimals. */ assetDecimals: number /** Higher of the organic and active target-yield rates at the closing boundary. */ baseRateBps: number /** Incremental boost annual rate applied at the closing boundary. */ boostRateBps?: number | undefined /** Number of accounting intervals covered by the run. */ intervalCount: number /** Confirmed depositor payout, when this run pushed rewards. */ payout?: { /** Recipients retaining an unpaid cumulative entitlement. */ deferredRecipients: number /** Recipients receiving an EarnShare transfer during this run. */ paidRecipients: number /** Confirmed payout transaction hashes. */ transactionHashes: readonly Hex[] } /** Confirmed boost settlement, when this run published a root. */ settlement?: { /** Base-asset units represented by this root increment. */ assetAmount: string /** Recipients receiving a positive allocation. */ creditedRecipients: number /** Newly credited recipients retaining an unpaid entitlement after delivery. */ deferredRecipients: number /** Confirmed root publication transaction hash. */ transactionHash: Hex } /** Target-yield evaluation for the run, including a confirmed funding transaction when needed. */ targetYield?: { /** Base-asset units funded across configured funding legs. */ assetAmount: string /** Confirmed funding transaction hash, omitted when no top-up was needed. */ transactionHash?: Hex | undefined } /** Curated vault label configured by an administrator. */ vaultLabel: string } } type Bindings = { controller?: Address | undefined distributor?: Address | undefined } type Calculation = { accounts: Projection.Account[] closingCursor?: Campaigns.EventCursor | undefined evidence: Campaigns.RunEvidence } function provisioningPlan(campaign: RewardCampaigns.Record): { boostRewards: Campaigns.Config['boostRewards'] configMatch: Campaigns.Config targetYield: Campaigns.Config['targetYield'] } { const pending = campaign.pendingConfig const needsPendingController = Boolean(pending?.targetYield && !campaign.controllerAddress) const needsPendingDistributor = Boolean(pending?.boostRewards && !campaign.distributorAddress) return { boostRewards: campaign.config.boostRewards ?? pending?.boostRewards, configMatch: pending && (needsPendingController || needsPendingDistributor) ? pending : campaign.config, targetYield: campaign.config.targetYield ?? pending?.targetYield, } } async function provision( options: run.Options, campaign: RewardCampaigns.Record, signer: Address, ): Promise { const plan = provisioningPlan(campaign) if (campaign.signerAddress && !isAddressEqual(campaign.signerAddress, signer)) throw new Error('Reward signer does not match the campaign signer.') const client = options.getClient(campaign.chainId) const vault = getAddress(campaign.vaultAddress) const treasury = plan.boostRewards?.treasury const [predictedController, predictedDistributor] = await Promise.all([ plan.targetYield ? client.readContract({ abi: factoryAbi, address: options.factory, args: [vault, signer], functionName: 'predictContributionController', }) : undefined, plan.boostRewards && treasury ? client.readContract({ abi: factoryAbi, address: options.factory, args: [vault, signer, treasury, claimDeadline], functionName: 'predictMerkleDistributor', }) : undefined, ]) if ( campaign.controllerAddress && predictedController && !isAddressEqual(campaign.controllerAddress, predictedController) ) throw new Error('Stored contribution controller does not match the campaign signer.') if ( campaign.distributorAddress && predictedDistributor && !isAddressEqual(campaign.distributorAddress, predictedDistributor) ) throw new Error('Stored Merkle distributor does not match the campaign signer.') const controller = plan.targetYield ? getAddress(campaign.controllerAddress ?? predictedController!) : undefined const distributor = plan.boostRewards ? getAddress(campaign.distributorAddress ?? predictedDistributor!) : undefined const code = await Promise.all( [controller, distributor] .filter((address): address is Address => Boolean(address)) .map((address) => client.getCode({ address })), ) if (code.some((runtime) => !runtime)) { const intent = { boostRewards: Boolean(plan.boostRewards), domain: domain(options, campaign, { ...(plan.boostRewards ? { boostRewards: plan.boostRewards } : {}), ...(plan.targetYield ? { targetYield: plan.targetYield } : {}), }), operation: 'deploy' as const, targetYield: Boolean(plan.targetYield), treasury: treasury ?? '0x0000000000000000000000000000000000000000', } await execute(options, campaign, undefined, signer, intent) } for (const address of [controller, distributor]) if (address && !(await client.getCode({ address }))) throw new Error(`Reward periphery was not deployed at ${address}.`) // A failed first deployment must not permanently bind a misconfigured signer. const bound = await RewardCampaigns.bindSigner(options.db, { chainId: campaign.chainId, signerAddress: signer, vaultAddress: vault, }) if (!bound) throw new Error('Reward signer does not match the campaign signer.') const stored = await RewardCampaigns.setBindings(options.db, { chainId: campaign.chainId, config: plan.configMatch, ...(controller ? { controllerAddress: controller } : {}), ...(distributor ? { distributorAddress: distributor } : {}), error: null, signerAddress: signer, vaultAddress: vault, }) if (!stored) throw new Error('Reward campaign changed during periphery provisioning.') return { ...(controller ? { controller } : {}), ...(distributor ? { distributor } : {}), } } async function calculate( options: run.Options, campaign: RewardCampaigns.Record, runRecord: RewardRuns.Record, bindings: Bindings, ): Promise { await requireTransition(options.db, runRecord, 'calculating', {}) const client = options.getClient(campaign.chainId) const tidx = options.getTidx(campaign.chainId) const startsAfter = Number(runRecord.startsAfter) const endsAt = Number(runRecord.endsAt) const intervalSeconds = Campaigns.intervalSeconds(campaign.config) const openingBoundary = await Events.boundary(tidx, startsAfter) const closingBoundary = await Events.boundary(tidx, endsAt) const boost = campaign.config.boostRewards const boostTracks = Boolean( boost && Campaigns.overlapsCompleteIntervals(boost, { endsAt, startsAfter }), ) const storedCheckpoint = await RewardRuns.getCheckpoint(options.db, { id: runRecord.id }) const eligible = boostTracks ? await allEligible(options.db, campaign) : [] const eligibilityCheckpoint = eligible.at(-1)?.registrationOrder ?? '0' const eligibilityVersion = Campaigns.eligibilityVersion(eligible) const excluded = boostTracks ? await exclusions(options, campaign, bindings) : [] const exclusionVersion = Campaigns.exclusionVersion(excluded) const saved = resumeCheckpoint(storedCheckpoint, { eligibilityVersion, exclusionVersion }) const stored = await RewardAccounts.list(options.db, campaign) let accounts = stored.map(Projection.fromRecord) let startingCursor = campaign.eventCursor if (boost && boostTracks) { const excludedSet = new Set(excluded.map((address) => address.toLowerCase())) const registrations = eligible .filter((entry) => { const address = entry.walletAddress.toLowerCase() return !excludedSet.has(address) }) .map((entry) => ({ recipient: entry.walletAddress, registeredAt: entry.latestRegisteredAt, registrationOrder: BigInt(entry.registrationOrder), })) const accountsByRecipient = new Map( accounts.map((account) => [account.recipient.toLowerCase(), account]), ) const reconcile = registrations.some((entry) => { const account = accountsByRecipient.get(entry.recipient.toLowerCase()) return account?.eligibilityRegisteredAt !== entry.registeredAt }) const replayRegistrations = registrations.filter((entry) => { const account = accountsByRecipient.get(entry.recipient.toLowerCase()) return ( account?.eligibilityRegisteredAt !== entry.registeredAt && (!account || (account.allocatedPrincipalAssets === 0n && account.lots.length === 0 && account.qualifiedEarnShares === 0n)) ) }) if (reconcile && saved?.stage !== 'calculate') { const resume = saved?.stage === 'reconcile' && saved.projection.pass ? saved : undefined let cursor = resume?.projection.replayedThrough const principalAllocationOrder = stored.length === 0 && Number(campaign.deliveredThrough) === boost.startTimestamp ? 'deposit' : 'registration' accounts = [ ...( await Projection.reconcilePages({ accounts, ...(resume ? { checkpoint: { accounts: resume.projection.accounts, ...(resume.projection.allocationRecipient ? { allocationRecipient: resume.projection.allocationRecipient } : {}), ...(resume.projection.consumedEarnShares ? { consumedEarnShares: resume.projection.consumedEarnShares } : {}), depositedEarnShares: resume.projection.depositedEarnShares ?? {}, ...(resume.cursor ? { cursor: resume.cursor } : {}), pass: resume.projection.pass!, ...(resume.projection.replayedThrough ? { replayedThrough: resume.projection.replayedThrough } : {}), }, } : {}), checkpointState: async (projection) => { cursor = projection.replayedThrough const persisted = await RewardRuns.writeCheckpoint(options.db, { evidence: { ...(projection.cursor ? { cursor: projection.cursor } : {}), kind: 'checkpoint', progress: { eligibilityCheckpoint, eligibilityVersion, exclusionVersion }, projection, stage: 'reconcile', }, fence: runRecord.fence, id: runRecord.id, }) if (!persisted) throw new Error('Reward run lease was lost.') }, eligible: registrations, excluded, pages: (pass, after) => pass === 'survival' || principalAllocationOrder === 'deposit' ? reconciliationEventPages( Events.accountEventPages(tidx, { ...(after ? { after } : {}), accounts: replayRegistrations.map((entry) => entry.recipient), earnShare: campaign.earnShareAddress, earnVault: campaign.vaultAddress, throughBlock: openingBoundary.blockNumber, }), ) : registrationDepositPages(tidx, { ...(after ? { after } : {}), ...(resume?.projection.allocationRecipient ? { afterRecipient: resume.projection.allocationRecipient } : {}), accounts: replayRegistrations, earnVault: campaign.vaultAddress, throughBlock: openingBoundary.blockNumber, }), perUserPrincipalCapAssets: BigInt(boost.perUserPrincipalCapAssets), // Initial allocation follows deposits; later registrations share the remaining cap by registration order. principalAllocationOrder, totalPrincipalCapAssets: BigInt(boost.totalPrincipalCapAssets), }) ).accounts, ] startingCursor = reconciledCursor({ committed: startingCursor, replayed: cursor }) } } const targetConfig = campaign.config.targetYield const targetActive = Boolean( targetConfig && Campaigns.overlapsCompleteIntervals(targetConfig, { endsAt, startsAfter }), ) if (saved?.stage === 'calculate') { accounts = saved.projection.accounts.map(Projection.fromSnapshot) startingCursor = saved.cursor ?? startingCursor } let eventCursor = boost && accounts.length > 0 ? startingCursor : { blockNumber: openingBoundary.blockNumber, logIndex: Number.MAX_SAFE_INTEGER, transactionIndex: Number.MAX_SAFE_INTEGER, } const capitalChanges = targetActive ? await Events.capitalChanges(tidx, { afterBlock: openingBoundary.blockNumber, earnShare: campaign.earnShareAddress, throughBlock: closingBoundary.blockNumber, }) : [] const boostAccrues = Boolean( boost && boost.enabled !== false && Campaigns.overlapsCompleteIntervals(boost, { endsAt, startsAfter }), ) // Carry external target observations and exact funding remainders through inactive intervals // so a later schedule resumption reads the latest durable evidence. const previous = await RewardRuns.latestDelivered(options.db, campaign) const target = targetActive ? (checkpointTargetRate(saved, targetConfig!) ?? (await targetRate(options, campaign, runRecord))) : undefined const targetRateProgress = target ? { ...(target.stale ? { staleRates: ['target' as const] } : {}), targetRateBps: target.annualRateBps, targetRateObservedAt: target.observedAt, } : {} const contributions = target || boostAccrues ? await Events.contributions(tidx, { afterBlock: openingBoundary.blockNumber, earnVault: campaign.vaultAddress, throughBlock: closingBoundary.blockNumber, }) : [] let latestBoostRate = saved?.progress.boostRateBps ?? 0 type RateObservation = { observedAt: string; rate: number } let latestOrganicRate: RateObservation | undefined = saved?.progress.organicRateBps !== undefined && saved.progress.organicRateObservedAt ? { observedAt: saved.progress.organicRateObservedAt, rate: saved.progress.organicRateBps, } : undefined const intervals: Campaigns.RunEvidence['intervals'][number][] = [ ...(saved?.progress.intervals ?? []), ] const targetAssets = target ? (saved?.progress.targetFundingAssets?.map(BigInt) ?? target.config.funding.map(() => 0n)) : [] const carryTargetRemainders = Boolean( targetConfig && sameTargetFunding(previous?.config.targetYield?.funding, targetConfig.funding), ) const targetRemainders = targetConfig ? (saved?.progress.targetFundingRemainders?.map(BigInt) ?? targetConfig.funding.map((_, index) => BigInt( carryTargetRemainders ? (previous?.evidence?.targetFundingRemainders?.[index] ?? 0) : 0, ), )) : [] let projectionCheckpoint = saved?.stage === 'calculate' ? saved : undefined for ( let end = startsAfter + (intervals.length + 1) * intervalSeconds; end <= endsAt; end += intervalSeconds ) { const start = end - intervalSeconds const intervalOpening = start === startsAfter ? openingBoundary : await Events.boundary(tidx, start) const boundary = end === endsAt ? closingBoundary : await Events.boundary(tidx, end) const sharePrice = await client.readContract({ abi: vaultAbi, address: campaign.vaultAddress, args: [10n ** 18n], blockNumber: BigInt(intervalOpening.blockNumber), functionName: 'previewRedeem', }) const closingSharePrice = await client.readContract({ abi: vaultAbi, address: campaign.vaultAddress, args: [10n ** 18n], blockNumber: BigInt(boundary.blockNumber), functionName: 'previewRedeem', }) const targetIntervalActive = Boolean( target && end > target.config.startTimestamp && end <= target.config.endTimestamp, ) const boostActive = Boolean( boost && boost.enabled !== false && end > boost.startTimestamp && end <= boost.endTimestamp, ) const intervalContributions = contributions .filter((entry) => entry.timestamp >= start && entry.timestamp < end) .map((entry) => ({ assets: entry.assets, earnShareSupply: entry.earnShareSupply })) const growth = targetIntervalActive || boostActive ? await (async () => { const [totalAssets, openingEarnShareSupply] = await Promise.all([ client.readContract({ abi: vaultAbi, address: campaign.vaultAddress, blockNumber: BigInt(intervalOpening.blockNumber), functionName: 'totalAssets', }), feeInclusiveEarnShareSupply( client, campaign.vaultAddress, BigInt(intervalOpening.blockNumber), ), ]) const assets = Campaigns.organicGrowth({ closingValuePerEarnShare: closingSharePrice, contributions: intervalContributions, openingAssets: totalAssets, openingEarnShareSupply, openingValuePerEarnShare: sharePrice, }) return { annualRateBps: Campaigns.annualRateBps({ elapsedSeconds: intervalSeconds, growthAssets: assets, principalAssets: totalAssets, }), opening: { openingAssets: totalAssets, openingEarnShareSupply, openingValuePerEarnShare: sharePrice, }, } })() : undefined if (boostActive && growth) latestOrganicRate = { observedAt: new Date(end * 1_000).toISOString(), rate: growth.annualRateBps, } const intervalBoostRate = boost && boostActive ? Campaigns.rewardRates({ boostTargetRateBps: boost.targetAnnualRateBps, organicRateBps: growth?.annualRateBps ?? 0, ...(targetIntervalActive ? { targetRateBps: target!.annualRateBps } : {}), }).boostRateBps : 0 if (boostActive) latestBoostRate = intervalBoostRate const resume = projectionCheckpoint const pages = boost && accounts.length > 0 ? Events.accountEventPages(tidx, { ...(eventCursor ? { after: eventCursor } : {}), accounts: accounts.map((account) => account.recipient), earnShare: campaign.earnShareAddress, earnVault: campaign.vaultAddress, throughBlock: boundary.blockNumber, }) : emptyEventPages() const result = boost ? await Projection.calculatePages({ accounts, annualRateBps: intervalBoostRate, assetPerEarnShareWad: sharePrice, ...(resume ? { checkpoint: { accounts: resume.projection.accounts, accruedThrough: resume.projection.accruedThrough ?? {}, weightedEarnShareSeconds: resume.projection.weightedEarnShareSeconds ?? {}, }, } : {}), checkpointState: async (projection, cursor) => { eventCursor = cursor const persisted = await RewardRuns.writeCheckpoint(options.db, { evidence: { cursor, kind: 'checkpoint', progress: { boostRateBps: latestBoostRate, eligibilityCheckpoint, eligibilityVersion, exclusionVersion, intervals, ...(latestOrganicRate ? { organicRateBps: latestOrganicRate.rate, organicRateObservedAt: latestOrganicRate.observedAt, } : {}), ...targetRateProgress, targetFundingAssets: targetAssets.map(String), targetFundingRemainders: targetRemainders.map(String), }, projection, stage: 'calculate', }, fence: runRecord.fence, id: runRecord.id, }) if (!persisted) throw new Error('Reward run lease was lost.') }, endsAt: end, excluded, pages, perUserPrincipalCapAssets: BigInt(boost.perUserPrincipalCapAssets), startsAt: start, totalPrincipalCapAssets: BigInt(boost.totalPrincipalCapAssets), }) : { accounts, rewards: new Map() } projectionCheckpoint = undefined accounts = [...result.accounts] intervals.push({ assetPerEarnShareWad: sharePrice.toString(), assetRewards: Object.fromEntries( [...result.rewards].map(([recipient, amount]) => [ recipient.toLowerCase(), amount.toString(), ]), ), blockNumber: boundary.blockNumber, ...(boost ? { boostRateBps: intervalBoostRate } : {}), endsAt: end, ...(growth ? { organicRateBps: growth.annualRateBps } : {}), startsAt: start, }) if (target && targetIntervalActive) { if (!growth) throw new Error('Target-yield interval lacks organic growth evidence.') const periods: Campaigns.targetFunding.Period[] = [] let opening = { ...growth.opening, blockNumber: intervalOpening.blockNumber, timestamp: start, } // Read post-change principal so a boundary deposit earns only until its redemption block. const states = await capitalStates(client, { boundaries: capitalChanges.filter( (entry) => entry.timestamp >= start && entry.timestamp < end, ), vaultAddress: campaign.vaultAddress, }) for (const closing of [ ...states, { ...growth.opening, blockNumber: boundary.blockNumber, openingValuePerEarnShare: closingSharePrice, timestamp: end, }, ]) { periods.push({ closingValuePerEarnShare: closing.openingValuePerEarnShare, contributions: contributions.filter( (entry) => entry.cursor.blockNumber > opening.blockNumber && entry.cursor.blockNumber <= closing.blockNumber, ), elapsedSeconds: closing.timestamp - opening.timestamp, openingAssets: opening.openingAssets, openingEarnShareSupply: opening.openingEarnShareSupply, openingValuePerEarnShare: opening.openingValuePerEarnShare, }) opening = closing } const funding = Campaigns.targetFunding({ annualRateBps: target.annualRateBps, funding: target.config.funding, periods, remainders: targetRemainders, }) funding.assets.forEach((amount, index) => { targetAssets[index] = (targetAssets[index] ?? 0n) + amount targetRemainders[index] = funding.remainders[index]! }) } const persisted = await RewardRuns.writeCheckpoint(options.db, { evidence: { ...(eventCursor ? { cursor: eventCursor } : {}), kind: 'checkpoint', progress: { boostRateBps: latestBoostRate, eligibilityCheckpoint, eligibilityVersion, exclusionVersion, intervals, ...(latestOrganicRate ? { organicRateBps: latestOrganicRate.rate, organicRateObservedAt: latestOrganicRate.observedAt, } : {}), ...targetRateProgress, targetFundingAssets: targetAssets.map(String), targetFundingRemainders: targetRemainders.map(String), }, projection: { accounts: accounts.map(Projection.snapshot) }, stage: 'calculate', }, fence: runRecord.fence, id: runRecord.id, }) if (!persisted) throw new Error('Reward run lease was lost.') } const priorTarget = (() => { if ( !targetConfig || target || previous?.evidence?.targetRateBps === undefined || !previous.evidence.targetRateObservedAt || !sameRateSource(previous.config.targetYield?.annualRate, targetConfig.annualRate) ) return undefined return { observedAt: previous.evidence.targetRateObservedAt, rate: previous.evidence.targetRateBps, } })() const targetEvidence = (() => { if (target) return { appliedTargetRateBps: target.annualRateBps, targetFundingAssets: targetAssets.map(String), targetFundingRemainders: targetRemainders.map(String), targetPrincipalMethod: 'time-weighted:v1' as const, targetRateBps: target.annualRateBps, targetRateObservedAt: target.observedAt, } if (!targetConfig) return {} return { targetFundingRemainders: targetRemainders.map(String), ...(priorTarget ? { targetRateBps: priorTarget.rate, targetRateObservedAt: priorTarget.observedAt, } : {}), } })() const evidence: Campaigns.RunEvidence = { ...(boost ? { boostRateBps: latestBoostRate } : {}), closingAccounts: accounts.map(Projection.snapshot), eligibilityCheckpoint, eligibilityVersion, exclusionVersion, ...(eventCursor ? { eventCursor } : {}), intervals, ...(target?.stale ? { staleRates: ['target' as const], } : {}), ...(latestOrganicRate ? { organicRateBps: latestOrganicRate.rate, organicRateObservedAt: latestOrganicRate.observedAt, } : {}), stateChecksum: Projection.checksum(accounts), ...targetEvidence, } await requireTransition(options.db, runRecord, 'targetYield', { evidence }) return { accounts, ...(eventCursor ? { closingCursor: eventCursor } : {}), evidence } } async function settleTargetYield( options: run.Options, campaign: RewardCampaigns.Record, runRecord: RewardRuns.Record, bindings: Bindings, calculation: Calculation, signer: Address, ): Promise { const target = campaign.config.targetYield if (!target || !bindings.controller) return undefined const requestedAssets = Campaigns.targetFundingAssets(calculation.evidence) if (requestedAssets.every((amount) => amount === 0n)) return undefined const active = target.funding.flatMap((leg, index) => { const amount = requestedAssets[index] ?? 0n return amount > 0n ? [{ amount, wallet: leg.wallet }] : [] }) const previous = await RewardTransactionAttempts.latestRecoverable(options.db, { operation: 'targetYield', runId: runRecord.id, }) const intent = previous?.signedBytes ? Signer.schema.Request.parse({ attemptId: previous.id, id: previous.intentId, intent: previous.intent.payload, }).intent : { controller: bindings.controller, domain: domain(options, campaign), funders: active.map((leg) => leg.wallet), maxEarnShareSupply: ( await feeInclusiveEarnShareSupply( options.getClient(campaign.chainId), campaign.vaultAddress, ) ).toString(), operation: 'targetYield' as const, requestedAssets: active.map((leg) => leg.amount.toString()), } if (intent.operation !== 'targetYield') throw new Error('Stored target-yield attempt contains another operation.') const attempt = await execute(options, campaign, runRecord.id, signer, intent) if (!attempt.transactionHash) throw new Error('Target-yield transaction lacks a hash.') const receipt = await options .getClient(campaign.chainId) .getTransactionReceipt({ hash: attempt.transactionHash }) const funded = parseEventLogs({ abi: controllerAbi, eventName: 'Funded', logs: receipt.logs, strict: true, }).filter((event) => isAddressEqual(event.address, bindings.controller!)) const exact = funded.length === active.length && funded.every((event, index) => { const expected = active[index]! return ( isAddressEqual(event.args.funder, expected.wallet) && event.args.requestedAssets === expected.amount && event.args.fundedAssets === expected.amount ) }) if (!exact) { const retryableZeroFunding = active.length === 1 && funded.length === 1 && isAddressEqual(funded[0]!.args.funder, active[0]!.wallet) && funded[0]!.args.requestedAssets === active[0]!.amount && funded[0]!.args.fundedAssets === 0n if (retryableZeroFunding) { const ineffective = await RewardTransactionAttempts.markIneffective(options.db, attempt.id) if (!ineffective) throw new Error('Confirmed zero target-yield funding could not be marked for retry.') throw new Error('Target-yield funding settled zero assets and will be retried.') } throw new Error('Target-yield funding did not settle every requested asset exactly.') } return attempt.transactionHash ?? undefined } /** Reads historical capital states in bounded waves, preserving order and rejecting incomplete evidence. */ export async function capitalStates( client: Pick, options: capitalStates.Options, ): Promise { const states: capitalStates.State[] = [] for (let offset = 0; offset < options.boundaries.length; offset += capitalStateBatchSize) { const results = await Promise.allSettled( options.boundaries.slice(offset, offset + capitalStateBatchSize).map(async (boundary) => { const blockNumber = BigInt(boundary.blockNumber) const reads = await Promise.allSettled([ client.readContract({ abi: vaultAbi, address: options.vaultAddress, blockNumber, functionName: 'totalAssets', }), feeInclusiveEarnShareSupply(client, options.vaultAddress, blockNumber), client.readContract({ abi: vaultAbi, address: options.vaultAddress, args: [10n ** 18n], blockNumber, functionName: 'previewRedeem', }), ]) const [openingAssets, openingEarnShareSupply, openingValuePerEarnShare] = reads.map( (result) => { if (result.status === 'rejected') throw result.reason return result.value }, ) return { ...boundary, openingAssets: openingAssets!, openingEarnShareSupply: openingEarnShareSupply!, openingValuePerEarnShare: openingValuePerEarnShare!, } }), ) for (const result of results) { if (result.status === 'rejected') throw result.reason states.push(result.value) } } return states } export declare namespace capitalStates { /** Historical blocks required for one accounting interval. */ type Options = { /** Ordered supply-change boundaries. */ boundaries: readonly Events.boundary.Result[] /** Vault whose principal and fee-inclusive conversion are sampled. */ vaultAddress: Address } /** Historical principal and conversion at a supply-change boundary. */ type State = Events.boundary.Result & Omit } async function feeInclusiveEarnShareSupply( client: Pick, vault: Address, blockNumber?: bigint, ): Promise { const [earnFees, supply] = await Promise.all([ client.readContract({ abi: vaultAbi, address: vault, blockNumber, functionName: 'earnFees' }), client.readContract({ abi: vaultAbi, address: vault, blockNumber, functionName: 'totalEarnShares', }), ]) const preview = await client.readContract({ abi: feesAbi, address: earnFees, blockNumber, functionName: 'previewAccruedFees', }) return supply + preview.totalFeeEarnShares } async function settleBoost( options: run.Options, campaign: RewardCampaigns.Record, runRecord: RewardRuns.Record, bindings: Bindings, calculation: Calculation, signer: Address, ): Promise<{ evidence: Campaigns.RunEvidence; statement: Campaigns.Statement } | undefined> { const boost = campaign.config.boostRewards if (!boost || !bindings.distributor) { await commit(options.db, campaign, runRecord, calculation.accounts, calculation.closingCursor) return undefined } if (runRecord.phase === 'statement') return publishPreparedStatement(options, campaign, runRecord, bindings.distributor, signer) const rewardAssets = calculation.accounts.reduce( (sum, account) => sum + account.pendingRewardAssets, 0n, ) if (rewardAssets <= 1n) { const hasDeferred = calculation.accounts.some( (account) => account.cumulativePaid < account.cumulativeEntitlement, ) const previous = hasDeferred ? await RewardRuns.latestStatement(options.db, campaign) : undefined if ( previous?.statement && previous.root && previous.rootVersion && previous.statementHash && previous.liability ) { const evidence = { ...calculation.evidence, closingAccounts: calculation.accounts.map(Projection.snapshot), } await requireTransition(options.db, runRecord, 'paying', { evidence, fundedAssets: '0', liability: previous.liability, mintedEarnShares: '0', root: previous.root, rootVersion: previous.rootVersion, statement: previous.statement, statementHash: previous.statementHash, }) return { evidence, statement: previous.statement } } await commit(options.db, campaign, runRecord, calculation.accounts, calculation.closingCursor) return undefined } const client = options.getClient(campaign.chainId) const quote = await vaultQuote(client, campaign.vaultAddress) const desiredShares = Campaigns.assetsToEarnShares({ assets: rewardAssets, ...quote }) // Preserve sub-share rewards until enough assets accrue to mint at least one EarnShare. if (desiredShares === 0n) { await commit(options.db, campaign, runRecord, calculation.accounts, calculation.closingCursor) return undefined } const allocation = Campaigns.allocate({ earnShares: desiredShares, rewards: calculation.accounts.map((account) => ({ assets: account.pendingRewardAssets, recipient: account.recipient, })), }) const settlementRecipientAddresses = [...allocation.entries()] .filter(([, amount]) => amount > 0n) .map(([recipient]) => recipient) for (const account of calculation.accounts) { account.cumulativeEntitlement += allocation.get(account.recipient) ?? 0n account.pendingRewardAssets = 0n } const statement = Campaigns.buildStatement({ campaignId: campaignId(campaign.vaultAddress), chainId: campaign.chainId, distributor: bindings.distributor, entitlements: calculation.accounts .filter((account) => account.cumulativeEntitlement > 0n) .map((account) => ({ cumulativeAmount: account.cumulativeEntitlement.toString(), recipient: account.recipient, })), }) const [version, entitlement, paid, inventory] = await Promise.all([ client.readContract({ abi: distributorAbi, address: bindings.distributor, functionName: 'rootVersion', }), client.readContract({ abi: distributorAbi, address: bindings.distributor, functionName: 'rootTotalEntitlement', }), client.readContract({ abi: distributorAbi, address: bindings.distributor, functionName: 'totalPaid', }), client.readContract({ abi: erc20Abi, address: campaign.earnShareAddress, args: [bindings.distributor], functionName: 'balanceOf', }), ]) if (paid > entitlement) throw new Error('Reward distributor paid total exceeds its published entitlement.') const outstanding = entitlement - paid if (inventory < outstanding) throw new Error('Reward distributor inventory is below its current unpaid liability.') const unreserved = inventory - outstanding const missingEarnShares = desiredShares > unreserved ? desiredShares - unreserved : 0n const fundingEarnShares = Campaigns.settlementFundingEarnShares(missingEarnShares) const fundedAssets = fundingEarnShares > 0n ? Campaigns.earnSharesToAssets({ earnShares: fundingEarnShares, ...quote }) : 0n const prepared = await requireTransition(options.db, runRecord, 'statement', { evidence: { ...calculation.evidence, closingAccounts: calculation.accounts.map(Projection.snapshot), publishedRoot: true, settledRewardAssets: rewardAssets.toString(), settlementRecipientAddresses, settlementRecipients: settlementRecipientAddresses.length, }, fundedAssets: fundedAssets.toString(), liability: statement.totalEntitlement, mintedEarnShares: missingEarnShares.toString(), root: statement.root, rootVersion: version.toString(), statement, statementHash: statement.statementHash, }) return publishPreparedStatement(options, campaign, prepared, bindings.distributor, signer) } async function publishPreparedStatement( options: run.Options, campaign: RewardCampaigns.Record, runRecord: RewardRuns.Record, distributor: Address, signer: Address, ): Promise<{ evidence: Campaigns.RunEvidence; statement: Campaigns.Statement }> { runRecord = await refreshPreparedStatement(options, campaign, runRecord, distributor) const { evidence, fundedAssets, mintedEarnShares, rootVersion, statement } = runRecord if ( !evidence || !statement || fundedAssets === null || mintedEarnShares === null || rootVersion === null ) throw new Error('Prepared reward statement is incomplete.') const publish = { distributor, domain: domain(options, campaign), expectedRootVersion: rootVersion, operation: 'publish' as const, statement, } const intent: Signer.Request['intent'] = BigInt(fundedAssets) === 0n ? publish : { assets: fundedAssets, distributor, domain: publish.domain, expectedRootVersion: rootVersion, funder: campaign.config.boostRewards!.funding.wallet, minEarnShares: mintedEarnShares, operation: 'settle', settlementId: Signer.id(publish), statement, } const attempt = await execute(options, campaign, runRecord.id, signer, intent) if (!attempt.transactionHash) throw new Error('Reward root transaction lacks a hash.') const client = options.getClient(campaign.chainId) const [confirmedVersion, confirmedRoot, confirmedStatementHash, confirmedEntitlement] = await Promise.all([ client.readContract({ abi: distributorAbi, address: distributor, functionName: 'rootVersion', }), client.readContract({ abi: distributorAbi, address: distributor, functionName: 'merkleRoot', }), client.readContract({ abi: distributorAbi, address: distributor, functionName: 'statementHash', }), client.readContract({ abi: distributorAbi, address: distributor, functionName: 'rootTotalEntitlement', }), ]) if ( confirmedVersion !== BigInt(rootVersion) + 1n || confirmedRoot !== statement.root || confirmedStatementHash !== statement.statementHash || confirmedEntitlement !== BigInt(statement.totalEntitlement) ) throw new Error('Confirmed reward statement does not match the prepared root.') await requireTransition(options.db, runRecord, 'paying', { error: null, rootVersion: confirmedVersion.toString(), }) return { evidence, statement } } async function refreshPreparedStatement( options: run.Options, campaign: RewardCampaigns.Record, runRecord: RewardRuns.Record, distributor: Address, ): Promise { const attempts = await RewardTransactionAttempts.listForRun(options.db, runRecord.id) if ( attempts.some( (attempt) => (attempt.intent.operation === 'publish' || attempt.intent.operation === 'settle') && ['signed', 'broadcast', 'confirmed'].includes(attempt.state), ) ) return runRecord if (!runRecord.statement || runRecord.rootVersion === null) throw new Error('Prepared reward statement is incomplete.') const client = options.getClient(campaign.chainId) const [version, paid, inventory] = await Promise.all([ client.readContract({ abi: distributorAbi, address: distributor, functionName: 'rootVersion', }), client.readContract({ abi: distributorAbi, address: distributor, functionName: 'totalPaid', }), client.readContract({ abi: erc20Abi, address: campaign.earnShareAddress, args: [distributor], functionName: 'balanceOf', }), ]) if (version !== BigInt(runRecord.rootVersion)) throw new Error('Reward root changed after statement preparation.') const entitlement = BigInt(runRecord.statement.totalEntitlement) if (paid > entitlement) throw new Error('Reward paid total exceeds the prepared entitlement.') const available = paid + inventory const missingEarnShares = entitlement > available ? entitlement - available : 0n const fundingEarnShares = Campaigns.settlementFundingEarnShares(missingEarnShares) const fundedAssets = fundingEarnShares > 0n ? Campaigns.earnSharesToAssets({ earnShares: fundingEarnShares, ...(await vaultQuote(client, campaign.vaultAddress)), }) : 0n if ( runRecord.fundedAssets === fundedAssets.toString() && runRecord.mintedEarnShares === missingEarnShares.toString() ) return runRecord return requireTransition(options.db, runRecord, 'statement', { fundedAssets: fundedAssets.toString(), mintedEarnShares: missingEarnShares.toString(), }) } async function vaultQuote( client: ReturnType, vault: Address, ): Promise<{ totalAssets: bigint; totalEarnShares: bigint }> { const [totalAssets, totalEarnShares] = await Promise.all([ client.readContract({ abi: vaultAbi, address: vault, functionName: 'totalAssets' }), feeInclusiveEarnShareSupply(client, vault), ]) return { totalAssets, totalEarnShares } } async function deliver( options: run.Options, campaign: RewardCampaigns.Record, runRecord: RewardRuns.Record, bindings: Bindings, statement: Campaigns.Statement | undefined, calculation: Calculation, signer: Address, ) { if (!statement || !bindings.distributor) return const { accounts, closingCursor, evidence } = calculation const working = accounts.map((account) => ({ ...account, lots: account.lots.map((lot) => ({ ...lot })), })) const client = options.getClient(campaign.chainId) const paidBefore = new Map( working.map((account) => [account.recipient.toLowerCase(), account.cumulativePaid]), ) await reconcilePaid(client, { accounts: working, distributor: bindings.distributor }) const payoutRecipients = await (async () => { if (evidence.payoutRecipients !== undefined) return evidence.payoutRecipients const endsAt = Number(runRecord.endsAt) // Payout decisions use the immutable schedule that accrued this run's rewards. A pending // schedule applies only after promotion creates a run with its own configuration snapshot. const closingBoost = runRecord.config.boostRewards! const push = Campaigns.shouldPushPayout(closingBoost, { endsAt, startsAfter: Number(runRecord.startsAfter), }) const flush = endsAt >= Campaigns.finalBoundary({ boostRewards: closingBoost }) const quote = push && !flush ? await vaultQuote(client, campaign.vaultAddress) : undefined // Preserve visible small balance increases while avoiding automatic pushes worth less than gas. const minimumAssets = 10n ** BigInt(Math.max(campaign.assetDecimals - 4, 0)) const selected = working .filter( (account) => push && account.cumulativePaid < account.cumulativeEntitlement && (flush || (quote && Campaigns.meetsPayoutMinimum({ cumulativeEntitlement: account.cumulativeEntitlement, cumulativePaid: account.cumulativePaid, minimumAssets, ...quote, }))), ) .map((account) => account.recipient) await requireTransition(options.db, runRecord, 'paying', { evidence: { ...evidence, closingAccounts: working.map(Projection.snapshot), payoutRecipients: selected, }, }) return selected })() const unpaidRecipients = new Set(payoutRecipients.map((recipient) => recipient.toLowerCase())) const batches = Campaigns.buildPayoutBatches(statement, { maximumSize: payoutBatchSize, recipients: unpaidRecipients, }) const bundles = chunks(batches, payoutCallsPerTransaction) const version = await client.readContract({ abi: distributorAbi, address: bindings.distributor, functionName: 'rootVersion', }) // Recipient-disjoint cumulative pushes commute. Tempo expiring nonces let each packed transaction // confirm independently, while bounded waves avoid overwhelming the signer and RPC. for (const wave of chunks(bundles, payoutTransactionConcurrency)) { const results = await Promise.allSettled( wave.map((bundle) => execute(options, campaign, runRecord.id, signer, { batches: bundle, distributor: bindings.distributor!, domain: domain(options, campaign), operation: 'push', rootVersion: version.toString(), statementHash: statement.statementHash, }), ), ) const failure = results.find((result) => result.status === 'rejected') if (failure?.status === 'rejected') throw failure.reason } await reconcilePaid(client, { accounts: working, distributor: bindings.distributor }) for (const account of working) account.deferral = (() => { if (account.cumulativePaid >= account.cumulativeEntitlement) return undefined if (unpaidRecipients.has(account.recipient.toLowerCase())) return 'transfer_unavailable' return account.deferral })() const settlementRecipients = new Set( (evidence.settlementRecipientAddresses ?? []).map((recipient) => recipient.toLowerCase()), ) const paidRecipients = evidence.paidRecipients ?? (settlementRecipients.size > 0 ? working.filter( (account) => settlementRecipients.has(account.recipient.toLowerCase()) && account.cumulativePaid >= account.cumulativeEntitlement, ).length : working.filter( (account) => unpaidRecipients.has(account.recipient.toLowerCase()) && account.cumulativePaid > (paidBefore.get(account.recipient.toLowerCase()) ?? 0n), ).length) const payoutPaidRecipients = evidence.payoutPaidRecipients ?? working.filter( (account) => account.cumulativePaid > (paidBefore.get(account.recipient.toLowerCase()) ?? 0n), ).length const deferredRecipients = evidence.deferredRecipients ?? Math.max( (settlementRecipients.size > 0 ? settlementRecipients.size : (evidence.settlementRecipients ?? 0)) - paidRecipients, 0, ) const pendingRecipients = evidence.pendingRecipients ?? working.filter((account) => account.cumulativePaid < account.cumulativeEntitlement).length await requireTransition(options.db, runRecord, 'paying', { evidence: { ...evidence, closingAccounts: working.map(Projection.snapshot), deferredRecipients, paidRecipients, payoutPaidRecipients, payoutRecipients, pendingRecipients, }, }) await commit(options.db, campaign, runRecord, working, closingCursor, statement) } async function reconcilePaid( client: ReturnType, options: reconcilePaid.Options, ) { for (const group of chunks(options.accounts, 500)) { const paid = await client.multicall({ allowFailure: true, contracts: group.map((account) => ({ abi: distributorAbi, address: options.distributor, args: [account.recipient], functionName: 'cumulativePaid' as const, })), }) paid.forEach((result, index) => { if (result.status !== 'success') throw new Error('Reward payment reconciliation failed.') group[index]!.cumulativePaid = result.result }) } } declare namespace reconcilePaid { /** Onchain cumulative-payment reconciliation input. */ type Options = { /** Mutable recipient projections updated with onchain paid amounts. */ accounts: readonly Projection.Account[] /** Merkle distributor holding cumulative payment state. */ distributor: Address } } async function execute( options: run.Options, campaign: RewardCampaigns.Record, runId: string | undefined, signer: Address, signerIntent: Signer.Request['intent'], ): Promise { const intentId = Signer.id(signerIntent) let attempt = await RewardTransactionAttempts.latestForIntent(options.db, { intentId, ...(runId ? { runId } : {}), }) if (attempt?.state === 'confirmed') return attempt const replacementOf = attempt?.state === 'expired' || attempt?.state === 'ineffective' || attempt?.state === 'reverted' ? attempt.id : undefined if (replacementOf) attempt = undefined if (!attempt) { try { attempt = await RewardTransactionAttempts.create(options.db, { chainId: campaign.chainId, intent: { id: intentId, operation: signerIntent.operation, payload: signerIntent }, ...(replacementOf ? { replacementOf } : {}), ...(runId ? { runId } : {}), signer, vaultAddress: campaign.vaultAddress, }) } catch (cause) { // Queue redelivery can race before either invocation observes the other's journal row. // The unique live-intent constraint elects one attempt; the loser resumes that evidence. if (!isUniqueViolation(cause)) throw cause attempt = await RewardTransactionAttempts.latestForIntent(options.db, { intentId, ...(runId ? { runId } : {}), }) if (!attempt) throw cause } } if (attempt.state === 'created') { const signed = await Signer.sign({ fetch: options.signerFetch, request: { attemptId: attempt.id, id: intentId, intent: signerIntent }, }) if (!isAddressEqual(signed.signer, signer)) throw new Error('Reward signer account changed.') attempt = (await RewardTransactionAttempts.recordSignature(options.db, { expiresAt: signed.expiresAt, id: attempt.id, nonce: signed.nonce, signedBytes: signed.signedBytes, transactionHash: signed.transactionHash, })) ?? attempt } if (!attempt.signedBytes || !attempt.transactionHash) throw new Error('Reward transaction was not durably signed.') const client = options.getClient(campaign.chainId) let receipt = await client .getTransactionReceipt({ hash: attempt.transactionHash }) .catch(() => undefined) const expiredBeforeBroadcast = !receipt && attempt.expiresAt && Number((await client.getBlock()).timestamp) * 1_000 >= Date.parse(attempt.expiresAt) if (expiredBeforeBroadcast) { const expired = await RewardTransactionAttempts.expire(options.db, attempt.id) if (!expired) throw new Error('Expired reward transaction could not be replaced.') return execute(options, campaign, runId, signer, signerIntent) } if (!receipt && (attempt.state === 'signed' || attempt.state === 'broadcast')) { await options.assertLease?.() try { await client.sendRawTransaction({ serializedTransaction: attempt.signedBytes }) } catch (cause) { receipt = await client .getTransactionReceipt({ hash: attempt.transactionHash }) .catch(() => undefined) if ( !receipt && (!(cause instanceof Error) || !/already known|already imported|known transaction/i.test(cause.message)) ) throw cause } await RewardTransactionAttempts.recordBroadcast(options.db, attempt.id) } if (!receipt) receipt = await client .waitForTransactionReceipt({ confirmations: 1, hash: attempt.transactionHash, timeout: 60_000, }) .catch(async (cause) => { const recovered = await client .getTransactionReceipt({ hash: attempt.transactionHash! }) .catch(() => undefined) if (recovered) return recovered const expiredOnchain = attempt.expiresAt && Number((await client.getBlock()).timestamp) * 1_000 >= Date.parse(attempt.expiresAt) if (!expiredOnchain) throw cause const expiredAttempt = await RewardTransactionAttempts.expire(options.db, attempt.id) if (!expiredAttempt) throw cause return undefined }) if (!receipt) return execute(options, campaign, runId, signer, signerIntent) if (receipt.status === 'pending') throw new Error(`Reward transaction ${attempt.transactionHash} is still pending.`) const canonical = await client.getBlock({ blockNumber: receipt.blockNumber }) if (canonical.hash !== receipt.blockHash) throw new Error('Reward receipt block is not canonical.') const confirmed = await RewardTransactionAttempts.confirm(options.db, { id: attempt.id, intent: compactIntent(intentId, signerIntent), receipt: { blockHash: receipt.blockHash, blockNumber: receipt.blockNumber.toString(), gasUsed: receipt.gasUsed.toString(), status: receipt.status, transactionHash: receipt.transactionHash, }, }) if (!confirmed || confirmed.receipt?.status !== 'success') throw new Error(`Reward transaction ${attempt.transactionHash} reverted.`) return confirmed } function isUniqueViolation(cause: unknown): cause is { code: '23505' } { return typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === '23505' } function compactIntent(id: Hex, intent: Signer.Request['intent']): Campaigns.TransactionIntent { if (intent.operation === 'push') return { id, operation: intent.operation, payload: { batchCount: intent.batches.length, distributor: intent.distributor, domain: intent.domain, recipientCount: intent.batches.reduce((sum, batch) => sum + batch.recipients.length, 0), rootVersion: intent.rootVersion, statementHash: intent.statementHash, }, } if (intent.operation === 'publish' || intent.operation === 'settle') { const { statement, ...payload } = intent return { id, operation: intent.operation, payload: { ...payload, statement: { campaignId: statement.campaignId, chainId: statement.chainId, distributor: statement.distributor, entryCount: statement.entries.length, root: statement.root, schemaVersion: statement.schemaVersion, statementHash: statement.statementHash, totalEntitlement: statement.totalEntitlement, }, }, } } return { id, operation: intent.operation, payload: intent } } function maintainLease(db: Db.Db, runRecord: RewardRuns.Record, now: () => number) { let failure: unknown let stopped = false const extend = async () => { if (stopped || failure !== undefined) return const renewed = await RewardRuns.renew(db, { fence: runRecord.fence, id: runRecord.id, leaseExpiresAt: new Date(now() + runLeaseMilliseconds).toISOString(), }) if (!renewed) failure = new Error('Reward run lease was lost.') } const captureFailure = (cause: unknown) => { failure = cause } let renewal = extend().catch(captureFailure) const timer = setInterval( () => { renewal = renewal.then(extend).catch(captureFailure) }, Math.floor(runLeaseMilliseconds / 2), ) return { async assert() { await renewal await extend() if (failure !== undefined) throw failure }, async ready() { await renewal if (failure !== undefined) throw failure }, async stop() { stopped = true clearInterval(timer) await renewal return failure }, } } async function commit( db: Db.Db, campaign: RewardCampaigns.Record, runRecord: RewardRuns.Record, accounts: readonly Projection.Account[], eventCursor: Campaigns.EventCursor | undefined, statement?: Campaigns.Statement, ) { const now = new Date().toISOString() await db.transaction(async (tx) => { await RewardAccounts.replace(tx, { accounts: accounts.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, registrationOrder: account.registrationOrder.toString(), updatedAt: now, })), chainId: campaign.chainId, vaultAddress: campaign.vaultAddress, }) const advanced = await RewardCampaigns.advance(tx, { chainId: campaign.chainId, deliveredThrough: Number(runRecord.endsAt), ...(eventCursor ? { eventCursor } : {}), expectedDeliveredThrough: Number(runRecord.startsAfter), vaultAddress: campaign.vaultAddress, }) if (!advanced) throw new Error('Reward campaign cursor changed before commit.') const current = await RewardRuns.get(tx, runRecord.id) const delivered = await RewardRuns.transition(tx, { fence: runRecord.fence, id: runRecord.id, patch: { error: null, ...(current?.evidence ? { evidence: Campaigns.compactEvidence(current.evidence) } : {}), }, phase: 'delivered', }) if (!delivered) throw new Error('Reward run lease changed before commit.') if (statement) await RewardRuns.pruneStatements(tx, { chainId: campaign.chainId, keepRunId: runRecord.id, vaultAddress: campaign.vaultAddress, }) }) } async function requireTransition( db: Db.Db, runRecord: RewardRuns.Record, phase: RewardRuns.Record['phase'], patch: RewardRuns.transition.Options['patch'], ) { const result = await RewardRuns.transition(db, { fence: runRecord.fence, id: runRecord.id, patch, phase, }) if (!result) throw new Error('Reward run lease was lost.') return result } function restoreCalculation(evidence: Campaigns.RunEvidence): Calculation { return { accounts: evidence.closingAccounts.map(Projection.fromSnapshot), ...(evidence.eventCursor ? { closingCursor: evidence.eventCursor } : {}), evidence, } } async function* emptyEventPages(): AsyncGenerator {} async function* reconciliationEventPages( pages: AsyncIterable, ): AsyncGenerator { for await (const events of pages) yield { events } } async function* registrationDepositPages( tidx: Tidx.Client, options: registrationDepositPages.Options, ): AsyncGenerator { const accounts = [...options.accounts].sort((left, right) => left.registrationOrder < right.registrationOrder ? -1 : left.registrationOrder > right.registrationOrder ? 1 : left.recipient.toLowerCase().localeCompare(right.recipient.toLowerCase()), ) const afterOrder = options.afterRecipient ? accounts.find((entry) => isAddressEqual(entry.recipient, options.afterRecipient!)) ?.registrationOrder : undefined if (options.afterRecipient && afterOrder === undefined) throw new Error('Reward reconciliation recipient is no longer registered.') for (const account of accounts) { if ( afterOrder !== undefined && (account.registrationOrder < afterOrder || (account.registrationOrder === afterOrder && !options.after)) ) continue const pages = Events.accountDepositPages(tidx, { ...(afterOrder === account.registrationOrder && options.after ? { after: options.after } : {}), account: account.recipient, earnVault: options.earnVault, throughBlock: options.throughBlock, }) for await (const events of pages) yield { allocationRecipient: account.recipient, events } yield { allocationRecipient: account.recipient, events: [] } } } declare namespace registrationDepositPages { type Options = { after?: Campaigns.EventCursor | undefined afterRecipient?: Address | undefined accounts: readonly { recipient: Address; registrationOrder: bigint }[] earnVault: Address throughBlock: number } } async function notificationSummary( db: Db.Db, campaign: RewardCampaigns.Record, runId: string, ): Promise { const record = await RewardRuns.get(db, runId) const evidence = record?.evidence if (!record || !evidence) return undefined const [attempts, vault] = await Promise.all([ RewardTransactionAttempts.confirmedForRun(db, runId), EarnVaultRecords.get(db, campaign), ]) const root = attempts.find( (attempt) => attempt.intent.operation === 'publish' || attempt.intent.operation === 'settle', ) const targetAttempt = attempts.find((attempt) => attempt.intent.operation === 'targetYield') const payoutTransactionHashes = attempts .filter((attempt) => attempt.intent.operation === 'push' && attempt.transactionHash) .map((attempt) => attempt.transactionHash!) const closingInterval = evidence.intervals.at(-1) const targetActive = Boolean( record.config.targetYield && Campaigns.overlapsCompleteIntervals(record.config.targetYield, { endsAt: Number(record.endsAt), startsAfter: Number(record.startsAfter), }), ) const targetActiveAtClose = Boolean( record.config.targetYield && closingInterval && closingInterval.endsAt > record.config.targetYield.startTimestamp && closingInterval.endsAt <= record.config.targetYield.endTimestamp, ) const boostActiveAtClose = Boolean( record.config.boostRewards && record.config.boostRewards.enabled !== false && closingInterval && closingInterval.endsAt > record.config.boostRewards.startTimestamp && closingInterval.endsAt <= record.config.boostRewards.endTimestamp, ) const targetRateBps = targetActiveAtClose ? (evidence.appliedTargetRateBps ?? evidence.targetRateBps) : undefined const settlement = (() => { if ( !evidence.publishedRoot || evidence.settledRewardAssets === undefined || evidence.settlementRecipients === undefined || !root?.transactionHash ) return undefined return { assetAmount: evidence.settledRewardAssets, creditedRecipients: evidence.settlementRecipients, deferredRecipients: evidence.deferredRecipients ?? Math.max(evidence.settlementRecipients - (evidence.paidRecipients ?? 0), 0), transactionHash: root.transactionHash, } })() if (!targetActive && !settlement && payoutTransactionHashes.length === 0) return undefined return { assetDecimals: campaign.assetDecimals, baseRateBps: Math.max(closingInterval?.organicRateBps ?? 0, targetRateBps ?? 0), ...(boostActiveAtClose ? { boostRateBps: closingInterval?.boostRateBps ?? 0 } : {}), intervalCount: evidence.intervals.length, ...(payoutTransactionHashes.length > 0 ? { payout: { deferredRecipients: evidence.pendingRecipients ?? 0, paidRecipients: evidence.payoutPaidRecipients ?? evidence.paidRecipients ?? 0, transactionHashes: payoutTransactionHashes, }, } : {}), ...(settlement ? { settlement } : {}), ...(targetActive ? { targetYield: { assetAmount: (evidence.targetFundingAssets ?? []) .reduce((sum, amount) => sum + BigInt(amount), 0n) .toString(), ...(targetAttempt?.transactionHash ? { transactionHash: targetAttempt.transactionHash } : {}), }, } : {}), vaultLabel: vault?.label ?? campaign.vaultAddress, } } 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, }) if (records.length + page.data.length > maximumRecipients) throw new Error(`Reward campaign exceeds ${maximumRecipients} registered recipients.`) records.push(...page.data) cursor = page.nextCursor ?? undefined } while (cursor) return records } async function targetRate( options: run.Options, campaign: RewardCampaigns.Record, runRecord: RewardRuns.Record, ) { const config = campaign.config.targetYield! const previous = await RewardRuns.latestDelivered(options.db, campaign) const fallback = previous?.evidence?.targetRateBps !== undefined && previous.evidence.targetRateObservedAt && sameRateSource(previous.config.targetYield?.annualRate, config.annualRate) ? { annualRateBps: previous.evidence.targetRateBps, observedAt: previous.evidence.targetRateObservedAt, } : undefined const result = await Rates.target({ boundary: new Date(Number(runRecord.endsAt) * 1_000).toISOString(), ...(fallback ? { fallback } : {}), fetch: options.fetch, now: () => new Date(options.now()), source: config.annualRate, }) return { config, ...result } } function sameRateSource( left: NonNullable['annualRate'] | undefined, right: NonNullable['annualRate'], ): boolean { if (!left) return false if ('bps' in left || 'bps' in right) return 'bps' in left && 'bps' in right && left.bps === right.bps return ( left.morphoVault.toLowerCase() === right.morphoVault.toLowerCase() && left.minBps === right.minBps && left.maxBps === right.maxBps && left.staleAfterSeconds === right.staleAfterSeconds ) } function sameTargetFunding( left: NonNullable['funding'] | undefined, right: NonNullable['funding'], ): boolean { return Boolean( left && left.length === right.length && left.every((leg, index) => { const compared = right[index]! if (leg.wallet.toLowerCase() !== compared.wallet.toLowerCase()) return false if ('remainder' in leg || 'remainder' in compared) return 'remainder' in leg && 'remainder' in compared return leg.upToAnnualRate.bps === compared.upToAnnualRate.bps }), ) } async function exclusions( options: run.Options, campaign: RewardCampaigns.Record, bindings: Bindings, ): Promise { const boost = campaign.config.boostRewards if (!boost) return [] const client = options.getClient(campaign.chainId) const [earnFees, engine, vault] = await Promise.all([ client.readContract({ abi: vaultAbi, address: campaign.vaultAddress, functionName: 'earnFees', }), client.readContract({ abi: vaultAbi, address: campaign.vaultAddress, functionName: 'engine', }), EarnVaultRecords.get(options.db, campaign), ]) const zoneContracts = options.zones .filter((zone) => zone.sourceId === campaign.chainId) .flatMap((zone) => Object.values(zone.contracts ?? {})) .flatMap(contractAddresses) return [ ...new Set( [ campaign.vaultAddress, campaign.earnShareAddress, earnFees, engine, boost.treasury, ...(bindings.controller ? [bindings.controller] : []), ...(bindings.distributor ? [bindings.distributor] : []), ...(vault?.zones.map((zone) => zone.earnRouter) ?? []), ...zoneContracts, ...boost.excludedAddresses, ].map((address) => getAddress(address).toLowerCase()), ), ].map(getAddress) } function contractAddresses(contract: unknown): Address[] { if (!contract || typeof contract !== 'object') return [] const value = contract as Record const address = value['address'] if (typeof address === 'string' && isAddress(address)) return [address] return Object.values(value).flatMap(contractAddresses) } function domain( options: run.Options, campaign: RewardCampaigns.Record, config = campaign.config, ): Signer.Domain { return { chainId: campaign.chainId, earnShare: getAddress(campaign.earnShareAddress), earnVault: getAddress(campaign.vaultAddress), factory: options.factory, treasury: config.boostRewards?.treasury ?? '0x0000000000000000000000000000000000000000', } } function campaignId(vault: Address): Hex { return `0x${'0'.repeat(24)}${vault.slice(2).toLowerCase()}` } function chunks(items: readonly item[], size: number): item[][] { const result: item[][] = [] for (let index = 0; index < items.length; index += size) result.push(items.slice(index, index + size)) return result }