import { concatHex, encodeAbiParameters, getAddress, keccak256, parseAbiParameters, stringToBytes, zeroAddress, type Address, type Hex, } from 'viem' import * as z from 'zod/mini' import * as Schema from '../Schema.js' const maximumUint256 = (1n << 256n) - 1n // EarnVault permits at most one basis point of conversion-floor loss. Funding at least 10,001 raw // EarnShare units makes the loss of less than one raw unit strictly smaller than that bound. const minimumSettlementFundingEarnShares = 10_001n // Keeps the exact provider batch comfortably inside the signer's 28M gas envelope. const maximumFundingLegs = 8 const leafParameters = parseAbiParameters('uint256,address,bytes32,address,uint256') const basisPoints = z .number() .check( z.int(), z.nonnegative(), z.lte(10_000), z.describe('Annual rate in basis points, where 10,000 is 100%.'), z.meta({ examples: [700] }), ) const positiveBasisPoints = basisPoints.check( z.positive(), z.describe('Positive annual rate in basis points, where 10,000 is 100%.'), ) const uint256 = Schema.DecimalString.check( z.refine((value) => BigInt(value) <= maximumUint256, { error: 'value exceeds uint256' }), z.describe('Unsigned 256-bit integer encoded as a decimal string.'), ) const nonzeroAddress = Schema.Address.check( z.refine((address) => address !== zeroAddress, { error: 'address must be nonzero' }), z.describe('Nonzero Tempo account or contract address.'), ) /** Zod schemas for Earn reward campaigns. */ export namespace schema { const AnnualRate = z .union([ z.strictObject({ bps: positiveBasisPoints }).check(z.describe('Fixed annual target rate.')), z .strictObject({ maxBps: basisPoints, minBps: basisPoints, morphoVault: nonzeroAddress, staleAfterSeconds: z .number() .check( z.int(), z.positive(), z.describe('Maximum age in seconds of a reusable Morpho rate observation.'), z.meta({ examples: [3_600] }), ), }) .check( z.refine((rate) => rate.minBps <= rate.maxBps, { error: 'Morpho minBps must not exceed maxBps', }), z.describe('Base Morpho vault rate source with configured safety bounds.'), ), ]) .check( z.describe('Fixed annual rate or bounded Base Morpho vault rate source.'), z.meta({ examples: [{ bps: 500 }] }), ) const FundingLeg = z .union([ z .strictObject({ upToAnnualRate: z .strictObject({ bps: positiveBasisPoints }) .check(z.describe('Cumulative annual target funded through this leg.')), wallet: nonzeroAddress, }) .check(z.describe('Funding wallet responsible up to one cumulative annual rate.')), z .strictObject({ remainder: z .literal(true) .check( z.describe('Marks the final wallet as responsible for the remaining target.'), z.meta({ examples: [true] }), ), wallet: nonzeroAddress, }) .check(z.describe('Final funding wallet responsible for the remaining target yield.')), ]) .check(z.describe('One ordered target-yield funding-waterfall leg.')) /** Target-yield configuration for one vault. */ export const TargetYield = z .strictObject({ annualRate: AnnualRate, endTimestamp: z .number() .check( z.int(), z.positive(), z.describe('Exclusive Unix timestamp when target-yield funding ends.'), z.meta({ examples: [1_788_195_600] }), ), funding: z.array(FundingLeg).check( z.minLength(1), z.maxLength(maximumFundingLegs), z.describe('Ordered funding waterfall ending in exactly one remainder wallet.'), z.meta({ examples: [[{ remainder: true, wallet: '0x5000000000000000000000000000000000000005' }]], }), ), intervalSeconds: z .number() .check( z.int(), z.gte(60), z.lte(300), z.describe('Target-yield accounting interval in seconds.'), z.meta({ examples: [300] }), ), startTimestamp: z .number() .check( z.int(), z.nonnegative(), z.describe('Inclusive Unix timestamp when target-yield funding starts.'), z.meta({ examples: [1_788_192_000] }), ), }) .check(z.describe('Target-yield configuration for one verified Earn vault.')) /** Automatic cumulative-payout schedule for one boost campaign. */ const PayoutSchedule = z .strictObject({ intervalSeconds: z .number() .check( z.int(), z.positive(), z.describe('Interval between automatic boost payouts, in seconds.'), z.meta({ examples: [86_400] }), ), startTimestamp: z .number() .check( z.int(), z.nonnegative(), z.describe('Unix timestamp after which the next settlement triggers the first payout.'), z.meta({ examples: [1_788_220_800] }), ), }) .check(z.describe('Automatic boost payout schedule.')) /** Registered-depositor boost configuration for one vault. */ export const BoostRewards = z .strictObject({ enabled: z .optional(z.boolean()) .check( z.describe('Whether new depositor boost rewards accrue during the configured schedule.'), z.meta({ examples: [true] }), ), endTimestamp: z .number() .check( z.int(), z.positive(), z.describe('Exclusive Unix timestamp when boost rewards end.'), z.meta({ examples: [1_788_195_600] }), ), excludedAddresses: z .array(nonzeroAddress) .check( z.describe('Additional public holders excluded from boost rewards.'), z.meta({ examples: [[]] }), ), funding: z.strictObject({ wallet: nonzeroAddress }).check( z.describe('Wallet funding EarnShare boost settlements.'), z.meta({ examples: [{ wallet: '0x5000000000000000000000000000000000000005' }], }), ), intervalSeconds: z .number() .check( z.int(), z.gte(60), z.lte(300), z.describe('Boost-reward accounting interval in seconds.'), z.meta({ examples: [300] }), ), payoutSchedule: z .optional(PayoutSchedule) .check( z.describe('Optional schedule for automatically pushing accumulated boost rewards.'), ), perUserPrincipalCapAssets: uint256.check( z.describe('Maximum qualifying principal per registered wallet in base-asset units.'), z.meta({ examples: ['25000000000'] }), ), startTimestamp: z .number() .check( z.int(), z.nonnegative(), z.describe('Inclusive Unix timestamp when boost rewards start.'), z.meta({ examples: [1_788_192_000] }), ), targetAnnualRateBps: positiveBasisPoints.check( z.describe('Target total annual rate for qualified principal, in basis points.'), ), totalPrincipalCapAssets: uint256.check( z.describe('Campaign-wide qualifying principal cap in base-asset units.'), z.meta({ examples: ['10000000000000'] }), ), treasury: nonzeroAddress.check( z.describe('Immutable recipient of unclaimed EarnShare returned by the distributor.'), ), }) .check(z.describe('Registered-depositor EarnShare boost configuration for one vault.')) /** Complete rewards configuration for one verified Earn vault. */ export const Config = z .strictObject({ boostRewards: z .optional(BoostRewards) .check(z.describe('Optional registered-depositor EarnShare boost configuration.')), targetYield: z .optional(TargetYield) .check(z.describe('Optional target-yield funding configuration.')), }) .check( z.refine((config) => Boolean(config.boostRewards || config.targetYield), { error: 'targetYield or boostRewards is required', }), z.refine( (config) => !config.targetYield || !config.boostRewards || config.targetYield.intervalSeconds === config.boostRewards.intervalSeconds, { error: 'targetYield and boostRewards must use one intervalSeconds value' }, ), z.refine( (config) => !config.targetYield || !config.boostRewards || Math.abs(config.targetYield.startTimestamp - config.boostRewards.startTimestamp) % config.targetYield.intervalSeconds === 0, { error: 'targetYield and boostRewards schedules must share one interval grid' }, ), z.refine( (config) => !config.targetYield || (config.targetYield.endTimestamp - config.targetYield.startTimestamp >= config.targetYield.intervalSeconds && config.targetYield.funding.filter((leg) => 'remainder' in leg).length === 1 && config.targetYield.funding.every( (leg, index) => !('remainder' in leg) || index === config.targetYield!.funding.length - 1, ) && config.targetYield.funding.every((leg, index, funding) => { if ('remainder' in leg || index === 0) return true const previous = funding[index - 1]! return ( 'upToAnnualRate' in previous && leg.upToAnnualRate.bps > previous.upToAnnualRate.bps ) }) && new Set(config.targetYield.funding.map((leg) => leg.wallet)).size === config.targetYield.funding.length), { error: 'targetYield schedule or funding waterfall is invalid' }, ), z.refine( (config) => !config.boostRewards || (config.boostRewards.endTimestamp - config.boostRewards.startTimestamp >= config.boostRewards.intervalSeconds && BigInt(config.boostRewards.perUserPrincipalCapAssets) > 0n && BigInt(config.boostRewards.perUserPrincipalCapAssets) <= BigInt(config.boostRewards.totalPrincipalCapAssets) && new Set(config.boostRewards.excludedAddresses).size === config.boostRewards.excludedAddresses.length), { error: 'boostRewards schedule or principal caps are invalid' }, ), z.refine( (config) => { const boost = config.boostRewards const payout = boost?.payoutSchedule return ( !boost || !payout || (payout.intervalSeconds > boost.intervalSeconds && payout.startTimestamp > boost.startTimestamp && payout.startTimestamp <= boost.endTimestamp) ) }, { error: 'boostRewards payoutSchedule must be slower than its accounting interval' }, ), z.describe('Complete reward configuration for one verified Earn vault.'), ) } /** Returns the single accounting interval shared by the configured capabilities. */ export function intervalSeconds(config: Config): number { return config.targetYield?.intervalSeconds ?? config.boostRewards!.intervalSeconds } /** Returns the final complete accounting boundary, excluding a trailing partial interval. */ export function finalBoundary(config: Config): number { return Math.max( ...[config.targetYield, config.boostRewards] .filter((schedule): schedule is NonNullable => schedule !== undefined) .map( (schedule) => schedule.startTimestamp + Math.floor((schedule.endTimestamp - schedule.startTimestamp) / schedule.intervalSeconds) * schedule.intervalSeconds, ), ) } /** Returns whether a completed accounting range overlaps one schedule's complete intervals. */ export function overlapsCompleteIntervals( schedule: NonNullable, options: overlapsCompleteIntervals.Options, ): boolean { const final = schedule.startTimestamp + Math.floor((schedule.endTimestamp - schedule.startTimestamp) / schedule.intervalSeconds) * schedule.intervalSeconds return options.endsAt > schedule.startTimestamp && options.startsAfter < final } export declare namespace overlapsCompleteIntervals { /** Completed accounting range tested against one reward schedule. */ type Options = { /** Inclusive closing accounting boundary. */ endsAt: number /** Exclusive opening accounting boundary. */ startsAfter: number } } /** Returns whether one completed run should automatically push accumulated boost rewards. */ export function shouldPushPayout( boost: NonNullable, options: shouldPushPayout.Options, ): boolean { const payout = boost.payoutSchedule if (!payout) return true const final = finalBoundary({ boostRewards: boost }) if (options.startsAfter < final && options.endsAt >= final) return true // Later campaign runs retry recipients whose forced final transfer was temporarily unavailable. if (options.startsAfter >= final) return true if (options.endsAt < payout.startTimestamp) return false const previous = options.startsAfter < payout.startTimestamp ? -1 : Math.floor((options.startsAfter - payout.startTimestamp) / payout.intervalSeconds) const current = Math.floor((options.endsAt - payout.startTimestamp) / payout.intervalSeconds) return current > previous } export declare namespace shouldPushPayout { /** Completed accounting range considered for an automatic payout. */ type Options = { /** Inclusive closing accounting boundary. */ endsAt: number /** Exclusive opening accounting boundary. */ startsAfter: number } } /** Converts base assets into EarnShare at the fee-inclusive vault NAV, rounding down. */ export function assetsToEarnShares(options: { assets: bigint totalAssets: bigint totalEarnShares: bigint }): bigint { if (options.assets < 0n) throw new Error('assets must be nonnegative') requirePositiveVaultQuote(options) return (options.assets * options.totalEarnShares) / options.totalAssets } /** Converts EarnShare into the assets needed at the fee-inclusive vault NAV, rounding up. */ export function earnSharesToAssets(options: { earnShares: bigint totalAssets: bigint totalEarnShares: bigint }): bigint { if (options.earnShares < 0n) throw new Error('EarnShare must be nonnegative') requirePositiveVaultQuote(options) if (options.earnShares === 0n) return 0n return ( (options.earnShares * options.totalAssets + options.totalEarnShares - 1n) / options.totalEarnShares ) } /** Returns the inventory mint needed to fund a missing liability without coarse conversion loss. */ export function settlementFundingEarnShares(missingEarnShares: bigint): bigint { if (missingEarnShares < 0n) throw new Error('missing EarnShare must be nonnegative') if (missingEarnShares === 0n) return 0n return missingEarnShares < minimumSettlementFundingEarnShares ? minimumSettlementFundingEarnShares : missingEarnShares } function requirePositiveVaultQuote(options: { totalAssets: bigint; totalEarnShares: bigint }) { if (options.totalAssets <= 0n || options.totalEarnShares <= 0n) throw new Error('vault quote requires positive assets and fee-inclusive EarnShare supply') } /** Removes explicit contributions from one interval's measured exchange-rate growth. */ export function organicGrowth(options: organicGrowth.Options): bigint { const change = organicChange(options) return change > 0n ? change : 0n } /** Removes explicit contributions from signed exchange-rate changes, retaining losses for interval aggregation. */ export function organicChange(options: organicChange.Options): bigint { if (options.openingAssets === 0n && options.openingEarnShareSupply === 0n) return 0n if ( options.openingAssets <= 0n || options.openingEarnShareSupply <= 0n || options.openingValuePerEarnShare <= 0n ) throw new Error('opening target-yield state must be positive') const gross = (options.openingAssets * (options.closingValuePerEarnShare - options.openingValuePerEarnShare)) / options.openingValuePerEarnShare const contributed = options.contributions.reduce((sum, contribution) => { if (contribution.earnShareSupply <= 0n) throw new Error('contribution EarnShare supply must be positive') return ( sum + (contribution.assets * options.openingEarnShareSupply) / contribution.earnShareSupply ) }, 0n) return gross - contributed } export declare namespace organicChange { /** Exchange-rate values and contributions whose signed change is measured. */ type Options = organicGrowth.Options } export declare namespace organicGrowth { /** One contribution normalized against its post-contribution holder supply. */ type Contribution = { /** Contributed base-asset units. */ assets: bigint /** Fee-inclusive EarnShare supply after the contribution. */ earnShareSupply: bigint } /** Target-yield interval values. */ type Options = { /** Closing base assets per fixed EarnShare quote. */ closingValuePerEarnShare: bigint /** Explicit contributions included in the closing value. */ contributions: readonly Contribution[] /** Opening active base assets. */ openingAssets: bigint /** Fee-inclusive opening EarnShare supply used to normalize mid-interval contributions. */ openingEarnShareSupply: bigint /** Opening base assets per fixed EarnShare quote. */ openingValuePerEarnShare: bigint } } /** Allocates an interval's target shortfall using principal held between EarnShare supply changes. */ export function targetFunding(options: targetFunding.Options): targetFunding.Result { // Boundary blocks can change NAV without adding elapsed principal exposure. const change = options.periods.reduce((sum, period) => sum + organicChange(period), 0n) const growthAssets = change > 0n ? change : 0n const principalAssetSeconds = options.periods.reduce((sum, period) => { if (!Number.isSafeInteger(period.elapsedSeconds) || period.elapsedSeconds < 0) throw new Error('target-yield period duration must be a nonnegative integer') return sum + period.openingAssets * BigInt(period.elapsedSeconds) }, 0n) let funded = 0n const remainders: bigint[] = [] const assets = options.funding.map((leg, index) => { const annualRateBps = 'remainder' in leg ? options.annualRateBps : Math.min(options.annualRateBps, leg.upToAnnualRate.bps) // Asset-seconds preserve sub-unit principal exposure without rounding an average balance. const accrued = accrue({ annualRateBps, elapsedSeconds: 1, principalAssets: principalAssetSeconds, remainder: options.remainders[index] ?? 0n, }) remainders.push(accrued.remainder) const amount = accrued.amount > growthAssets + funded ? accrued.amount - growthAssets - funded : 0n funded += amount return amount }) return { assets, remainders } } export declare namespace targetFunding { /** One period with unchanged EarnShare supply. */ type Period = organicGrowth.Options & { /** Seconds for which the opening principal remained active. */ elapsedSeconds: number } /** Target funding calculation for one complete accounting interval. */ type Options = { /** Resolved target annual rate in basis points. */ annualRateBps: number /** Ordered funding waterfall. */ funding: readonly NonNullable['funding'][number][] /** Historical periods covering the complete interval. */ periods: readonly Period[] /** Exact accrual remainders carried from the preceding interval. */ remainders: readonly bigint[] } /** Exact assets and accrual remainders for each funding leg. */ type Result = { /** Assets requested from each funding wallet. */ assets: bigint[] /** Accrual remainders retained for the next interval. */ remainders: bigint[] } } /** Annualizes contribution-adjusted asset growth as a simple ACT/365 rate. */ export function annualRateBps(options: annualRateBps.Options): number { if (!Number.isSafeInteger(options.elapsedSeconds) || options.elapsedSeconds <= 0) throw new Error('elapsed seconds must be positive') if (options.growthAssets < 0n) throw new Error('growth assets must be nonnegative') if (options.principalAssets < 0n) throw new Error('principal assets must be nonnegative') if (options.principalAssets === 0n) return 0 const rate = (options.growthAssets * 10_000n * 365n * 24n * 60n * 60n) / (options.principalAssets * BigInt(options.elapsedSeconds)) if (rate > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('annual rate exceeds safe range') return Number(rate) } export declare namespace annualRateBps { /** Inputs for annualizing exact interval growth. */ type Options = { /** Closed interval length in seconds. */ elapsedSeconds: number /** Contribution-adjusted growth in base-asset units. */ growthAssets: bigint /** Opening base-asset principal. */ principalAssets: bigint } } /** Resolves the effective base and incremental boost rates for one interval. */ export function rewardRates(options: rewardRates.Options): rewardRates.Result { for (const rate of [ options.boostTargetRateBps, options.organicRateBps, options.targetRateBps ?? 0, ]) if (!Number.isSafeInteger(rate) || rate < 0) throw new Error('reward rate is invalid') const baseRateBps = Math.max(options.organicRateBps, options.targetRateBps ?? 0) return { baseRateBps, boostRateBps: Math.max(options.boostTargetRateBps - baseRateBps, 0), } } export declare namespace rewardRates { /** Rate targets and observed growth for one closed interval. */ type Options = { /** Total annual rate targeted for qualified depositors. */ boostTargetRateBps: number /** Contribution-adjusted annual rate observed during the interval. */ organicRateBps: number /** Active target-yield floor. */ targetRateBps?: number | undefined } /** Effective base and incremental boost rates for one closed interval. */ type Result = { /** Higher of organic growth and the active target-yield floor. */ baseRateBps: number /** Incremental depositor rate needed to reach the total target. */ boostRateBps: number } } /** Complete rewards configuration for one verified Earn vault. */ export type Config = z.output /** Ordered TIDX event cursor for one campaign projection. */ export type EventCursor = { /** Tempo block number. */ blockNumber: number /** Log index within the transaction receipt. */ logIndex: number /** Transaction index within the block. */ transactionIndex: number } /** One surviving public deposit lot used for boost qualification. */ export type DepositLot = { /** Principal allocated against campaign caps. */ allocatedPrincipalAssets: string /** Original deposited base assets. */ depositedAssets: string /** EarnShare minted by the deposit. */ depositedEarnShares: string /** Event order used for deterministic qualification. */ event: EventCursor /** Qualified EarnShare not consumed by an outgoing transfer or burn. */ remainingEarnShares: string } /** Calculation and delivery state retained while one run is active. */ export type RunEvidence = { /** Target annual rate applied to this run's intervals. */ appliedTargetRateBps?: number | undefined /** Incremental boost annual rate selected for the run. */ boostRateBps?: number | undefined /** Complete closing recipient projection used to resume payout after a crash. */ closingAccounts: readonly AccountSnapshot[] /** Newly credited recipients retaining an unpaid entitlement after delivery. */ deferredRecipients?: number | undefined /** Stable registration high-water mark consumed by the run. */ eligibilityCheckpoint?: string | undefined /** Exact eligibility snapshot identity used by resumable calculation. */ eligibilityVersion?: Hex | undefined /** Exact effective exclusion set identity used by resumable calculation. */ exclusionVersion?: Hex | undefined /** Final TIDX event applied to the closing projection. */ eventCursor?: EventCursor | undefined /** Rate sources using a bounded prior observation. */ staleRates?: readonly ('organic' | 'target')[] | undefined /** Contribution-adjusted annual rate observed in the run's latest boost interval. */ organicRateBps?: number | undefined /** Closing boundary of the interval supplying the organic rate. */ organicRateObservedAt?: string | undefined /** Newly credited recipients whose cumulative entitlement is fully paid after delivery. */ paidRecipients?: number | undefined /** Recipients receiving an EarnShare transfer during this run. */ payoutPaidRecipients?: number | undefined /** Frozen automatic-payout recipients reused across delivery retries. */ payoutRecipients?: readonly Address[] | undefined /** Recipients retaining an unpaid cumulative entitlement after automatic delivery. */ pendingRecipients?: number | undefined /** Latest target annual rate observation retained for source fallback. */ targetRateBps?: number | undefined /** Exact per-funder target-yield assets committed before signing. */ targetFundingAssets?: readonly string[] | undefined /** Per-waterfall-threshold division remainders carried into the next run. */ targetFundingRemainders?: readonly string[] | undefined /** Principal accounting method used for the committed funding amounts. */ targetPrincipalMethod?: 'time-weighted:v1' | undefined /** When the selected external target rate was observed. */ targetRateObservedAt?: string | undefined /** Per-interval calculation records in chronological order. */ intervals: readonly { /** Fee-inclusive opening assets per EarnShare, scaled by 1e18. */ assetPerEarnShareWad: string /** Per-recipient base-asset rewards for this interval. */ assetRewards: Readonly> /** Final TIDX block included in the interval. */ blockNumber: number /** Incremental boost annual rate applied to this interval. */ boostRateBps?: number | undefined /** Exclusive interval end timestamp. */ endsAt: number /** Contribution-adjusted annual rate observed in this interval. */ organicRateBps?: number | undefined /** Inclusive interval start timestamp. */ startsAt: number }[] /** Explicit completed-state discriminator; absent on legacy completed evidence. */ kind?: 'complete' | undefined /** Whether this run prepared and confirmed a new cumulative root. */ publishedRoot?: boolean | undefined /** Base-asset units converted into the newly published entitlement. */ settledRewardAssets?: string | undefined /** Recipients receiving a positive allocation in the new root. */ settlementRecipientAddresses?: readonly Address[] | undefined /** Recipients receiving a positive allocation in the new root. */ settlementRecipients?: number | undefined /** Closing account-state checksum. */ stateChecksum: Hex } /** Returns a stable identity for the complete eligibility snapshot. */ export function eligibilityVersion(entries: readonly eligibilityVersion.Entry[]): Hex { return keccak256( stringToBytes( JSON.stringify( entries.map((entry) => [ entry.walletAddress.toLowerCase(), entry.registrationOrder, entry.latestRegisteredAt, ]), ), ), ) } export declare namespace eligibilityVersion { /** Eligibility fields that can change projection membership or replay. */ type Entry = { /** Latest registration timestamp. */ latestRegisteredAt: string /** Stable registration order. */ registrationOrder: string /** Registered reward recipient. */ walletAddress: Address } } /** Returns a stable identity for the effective reward exclusion set. */ export function exclusionVersion(addresses: readonly Address[]): Hex { return keccak256( stringToBytes( JSON.stringify([...new Set(addresses.map((address) => address.toLowerCase()))].sort()), ), ) } /** Durable pagewise calculation state retained until complete evidence replaces it. */ export type RunCheckpointEvidence = { /** Final TIDX event included in this checkpoint. */ cursor?: EventCursor | undefined /** Stored-evidence discriminator. */ kind: 'checkpoint' /** Completed calculation progress needed to resume the run. */ progress: Partial /** Serializable projection reducer state. */ projection: { /** Recipient whose registration-ordered deposit page was last applied. */ allocationRecipient?: Address | undefined /** Account projections after the checkpointed page. */ accounts: readonly AccountSnapshot[] /** Last accrued timestamp keyed by normalized recipient. */ accruedThrough?: Readonly> | undefined /** Remaining FIFO-consumed shares while allocating surviving deposits. */ consumedEarnShares?: Readonly> | undefined /** Gross historical deposit shares keyed by normalized recipient. */ depositedEarnShares?: Readonly> | undefined /** Historical reconciliation pass. */ pass?: 'allocation' | 'survival' | undefined /** Final canonical event applied by the survival pass. */ replayedThrough?: EventCursor | undefined /** Weighted EarnShare seconds keyed by normalized recipient. */ weightedEarnShareSeconds?: Readonly> | undefined } /** Calculation reducer that produced this checkpoint. */ stage: 'calculate' | 'reconcile' } /** Completed or resumable evidence stored on a reward run. */ export type StoredRunEvidence = RunCheckpointEvidence | RunEvidence /** Narrows stored run evidence to resumable calculation state. */ export function isRunCheckpointEvidence( evidence: StoredRunEvidence | null | undefined, ): evidence is RunCheckpointEvidence { return evidence?.kind === 'checkpoint' } /** Restores funding amounts only when their saved principal accounting is safe to submit. */ export function targetFundingAssets(evidence: RunEvidence): bigint[] { const assets = (evidence.targetFundingAssets ?? []).map(BigInt) // Legacy evidence can contain inflated spot-principal amounts, including on a signed retry. if (assets.some((amount) => amount > 0n) && evidence.targetPrincipalMethod !== 'time-weighted:v1') throw new Error('Stored target-yield funding requires principal accounting review.') return assets } /** Removes active recipient calculation and payout state after a run is durably delivered. */ export function compactEvidence(evidence: RunEvidence): RunEvidence { const compacted: RunEvidence = { ...evidence, closingAccounts: [], intervals: evidence.intervals.map((interval) => ({ ...interval, assetRewards: {} })), payoutRecipients: [], settlementRecipientAddresses: [], } return compacted } /** Serializable reward-account projection retained by an in-flight run. */ export type AccountSnapshot = { /** Arithmetic remainder carried across intervals. */ accrualRemainder: string /** Principal allocated against campaign caps. */ allocatedPrincipalAssets: string /** Cumulative EarnShare entitlement. */ cumulativeEntitlement: string /** Cumulative EarnShare already paid. */ cumulativePaid: string /** Current transfer deferral. */ deferral: string | null /** Eligibility registration version consumed by this projection. */ eligibilityRegisteredAt: string | null /** Surviving qualified deposits. */ lots: readonly DepositLot[] /** Base-asset reward awaiting settlement. */ pendingRewardAssets: string /** Current attributable public EarnShare. */ publicEarnShares: string /** Current qualified EarnShare. */ qualifiedEarnShares: string /** Registered recipient. */ recipient: Address /** Monotonic registration order. */ registrationOrder: string } /** Typed signer intent persisted before transaction construction. */ export type TransactionIntent = { /** Deterministic content-addressed intent id. */ id: Hex /** Operation accepted by the reward signer. */ operation: 'deploy' | 'targetYield' | 'settle' | 'publish' | 'push' /** Operation-specific data validated by the signer. */ payload: Readonly> } /** Canonical receipt evidence retained after confirmation. */ export type TransactionReceipt = { /** Canonical receipt block hash. */ blockHash: Hex /** Receipt block number. */ blockNumber: string /** Gas consumed by the transaction. */ gasUsed: string /** Receipt status. */ status: 'success' | 'reverted' /** Transaction hash. */ transactionHash: Hex } /** One cumulative recipient entitlement. */ export type Entitlement = { /** EarnShare owed cumulatively. */ cumulativeAmount: string /** Committed recipient. */ recipient: Address } /** One proof-bearing statement entry. */ export type StatementEntry = Entitlement & { /** Domain-separated leaf hash. */ leaf: Hex } /** One cumulative v2 Merkle statement. */ export type Statement = { /** Zero-padded EarnVault campaign id. */ campaignId: Hex /** Tempo chain containing the distributor. */ chainId: number /** EarnShare distributor address. */ distributor: Address /** Sorted cumulative entries. */ entries: readonly StatementEntry[] /** Merkle root. */ root: Hex /** Statement schema version. */ schemaVersion: 2 /** Content hash of the statement payload. */ statementHash: Hex /** Sum of all cumulative entitlements. */ totalEntitlement: string } /** One permissionless cumulative multiproof payout batch. */ export type PayoutBatch = { /** Cumulative amounts matching `recipients`. */ cumulativeAmounts: readonly string[] /** StandardMerkleTree multiproof. */ multiproof: readonly Hex[] /** OpenZeppelin multiproof queue flags. */ proofFlags: readonly boolean[] /** Committed recipients in multiproof leaf order. */ recipients: readonly Address[] } /** Returns the next interval boundary relative to a schedule origin. */ export function nextBoundary(options: nextBoundary.Options): number { if (!Number.isSafeInteger(options.intervalSeconds) || options.intervalSeconds <= 0) throw new Error('intervalSeconds is invalid') if (!Number.isSafeInteger(options.origin) || options.origin < 0) throw new Error('origin is invalid') if (!Number.isSafeInteger(options.timestamp) || options.timestamp < options.origin) throw new Error('timestamp is invalid') return ( options.origin + (Math.floor((options.timestamp - options.origin) / options.intervalSeconds) + 1) * options.intervalSeconds ) } export declare namespace nextBoundary { /** Inputs for locating a schedule-relative interval boundary. */ type Options = { /** Accounting interval length in seconds. */ intervalSeconds: number /** Unix timestamp anchoring the schedule's interval grid. */ origin: number /** Unix timestamp after which the boundary must fall. */ timestamp: number } } /** Calculates simple ACT/365 reward assets and the carried division remainder. */ export function accrue(options: accrue.Options): accrue.Result { const denominator = 10_000n * 365n * 24n * 60n * 60n const numerator = options.principalAssets * BigInt(options.annualRateBps) * BigInt(options.elapsedSeconds) + options.remainder return { amount: numerator / denominator, remainder: numerator % denominator } } export declare namespace accrue { /** Inputs for one simple annual reward calculation. */ type Options = { /** Annual reward rate in basis points. */ annualRateBps: number /** Seconds covered by the calculation. */ elapsedSeconds: number /** Base-asset principal earning rewards. */ principalAssets: bigint /** Division remainder carried from earlier intervals. */ remainder: bigint } /** Exact integer result and its remainder. */ type Result = { /** Whole base-asset units earned. */ amount: bigint /** Numerator remainder carried into the next interval. */ remainder: bigint } } /** Allocates every minted EarnShare unit by deterministic largest remainder. */ export function allocate(options: allocate.Options): ReadonlyMap { const rewards = [...options.rewards] .filter((reward) => reward.assets > 0n) .sort((left, right) => left.recipient.toLowerCase().localeCompare(right.recipient.toLowerCase()), ) const total = rewards.reduce((sum, reward) => sum + reward.assets, 0n) if (total === 0n) { if (options.earnShares !== 0n) throw new Error('cannot allocate shares without rewards') return new Map() } const rows = rewards.map((reward) => { const numerator = reward.assets * options.earnShares return { amount: numerator / total, recipient: reward.recipient, remainder: numerator % total } }) let remaining = options.earnShares - rows.reduce((sum, row) => sum + row.amount, 0n) rows.sort( (left, right) => Number(right.remainder > left.remainder) - Number(right.remainder < left.remainder) || left.recipient.toLowerCase().localeCompare(right.recipient.toLowerCase()), ) for (const row of rows) { if (remaining === 0n) break row.amount += 1n remaining -= 1n } return new Map(rows.map((row) => [row.recipient, row.amount])) } export declare namespace allocate { /** Inputs for exact minted-share allocation. */ type Options = { /** Exact EarnShare units minted. */ earnShares: bigint /** Per-recipient base-asset rewards. */ rewards: readonly { assets: bigint; recipient: Address }[] } } /** Whether an unpaid EarnShare balance meets its base-asset payout floor. */ export function meetsPayoutMinimum(options: meetsPayoutMinimum.Options): boolean { const unpaid = options.cumulativeEntitlement - options.cumulativePaid if (unpaid <= 0n) return false if (options.minimumAssets < 0n) throw new Error('minimum assets must be nonnegative') if (options.totalAssets < 0n || options.totalEarnShares <= 0n) throw new Error('payout valuation requires nonnegative assets and positive EarnShare supply') return unpaid * options.totalAssets >= options.minimumAssets * options.totalEarnShares } export declare namespace meetsPayoutMinimum { /** Cumulative entitlement and current vault-value inputs. */ type Options = { /** Recipient's cumulative EarnShare entitlement. */ cumulativeEntitlement: bigint /** Recipient's cumulative paid EarnShare amount. */ cumulativePaid: bigint /** Minimum base-asset value required for automatic payout. */ minimumAssets: bigint /** Current fee-inclusive vault assets. */ totalAssets: bigint /** Current fee-inclusive EarnShare supply. */ totalEarnShares: bigint } } /** Builds an OpenZeppelin StandardMerkleTree-compatible cumulative statement. */ export function buildStatement(options: buildStatement.Options): Statement { if (options.entitlements.length === 0) throw new Error('statement requires an entitlement') const entries = options.entitlements .map((entry) => ({ cumulativeAmount: BigInt(entry.cumulativeAmount).toString(), leaf: '' as Hex, recipient: getAddress(entry.recipient), })) .sort((left, right) => left.recipient.toLowerCase().localeCompare(right.recipient.toLowerCase()), ) let totalEntitlement = 0n for (const [index, entry] of entries.entries()) { const cumulativeAmount = BigInt(entry.cumulativeAmount) if (cumulativeAmount <= 0n || cumulativeAmount > maximumUint256) throw new Error(`invalid entitlement for ${entry.recipient}`) if (index > 0 && entry.recipient === entries[index - 1]!.recipient) throw new Error(`duplicate recipient ${entry.recipient}`) totalEntitlement += cumulativeAmount entry.leaf = rewardLeaf({ ...options, cumulativeAmount, recipient: entry.recipient }) } if (totalEntitlement > maximumUint256) throw new Error('total entitlement exceeds uint256') const tree = makeTree(entries.map((entry) => entry.leaf)) const payload = { campaignId: options.campaignId, chainId: options.chainId, distributor: getAddress(options.distributor), entries, root: tree.nodes[0]!, schemaVersion: 2 as const, totalEntitlement: totalEntitlement.toString(), } return { ...payload, statementHash: keccak256(stringToBytes(JSON.stringify(payload))) } } export declare namespace buildStatement { /** Domain and entitlement inputs for one statement. */ type Options = { /** Zero-padded EarnVault campaign id. */ campaignId: Hex /** Tempo chain containing the distributor. */ chainId: number /** EarnShare distributor address. */ distributor: Address /** Cumulative recipient entitlements. */ entitlements: readonly Entitlement[] } } /** Splits a cumulative statement into contiguous multiproof payout batches. */ export function buildPayoutBatches( statement: Statement, options: buildPayoutBatches.Options, ): readonly PayoutBatch[] { if (!Number.isSafeInteger(options.maximumSize) || options.maximumSize <= 0) throw new Error('maximumSize must be a positive integer') const entries = statement.entries.flatMap((entry, index) => !options.recipients || options.recipients.has(entry.recipient.toLowerCase()) ? [{ entry, index }] : [], ) const leaves = statement.entries.map((entry) => entry.leaf) const tree = makeTree(leaves) const byPosition = new Map(tree.positions.map((position, index) => [position, index])) const result: PayoutBatch[] = [] for (let offset = 0; offset < entries.length; offset += options.maximumSize) { const selected = entries.slice(offset, offset + options.maximumSize) const positions = selected .map(({ index }) => tree.positions[index]!) .sort((left, right) => right - left) const original = [...positions] const stack = [...positions] const multiproof: Hex[] = [] const proofFlags: boolean[] = [] while (stack.length > 0 && stack[0]! > 0) { const current = stack.shift()! const sibling = current % 2 === 0 ? current - 1 : current + 1 const parent = Math.floor((current - 1) / 2) if (sibling === stack[0]) { proofFlags.push(true) stack.shift() } else { proofFlags.push(false) multiproof.push(tree.nodes[sibling]!) } stack.push(parent) } const ordered = original.map((position) => statement.entries[byPosition.get(position)!]!) result.push({ cumulativeAmounts: ordered.map((entry) => entry.cumulativeAmount), multiproof, proofFlags, recipients: ordered.map((entry) => entry.recipient), }) } return result } /** Rebuilds one individual recovery proof from the current cumulative statement. */ export function buildProof(statement: Statement, recipient: Address): readonly Hex[] | undefined { const index = statement.entries.findIndex( (entry) => entry.recipient.toLowerCase() === recipient.toLowerCase(), ) if (index < 0) return undefined return treeProof(makeTree(statement.entries.map((entry) => entry.leaf)), index) } export declare namespace buildPayoutBatches { /** Runtime batch constraints. */ type Options = { /** Maximum recipients in one bounded payout call. */ maximumSize: number /** Optional preflight-authorized recipient set. */ recipients?: ReadonlySet | undefined } } /** Computes the distributor's domain-separated standard leaf. */ export function rewardLeaf(options: rewardLeaf.Options): Hex { return keccak256( keccak256( encodeAbiParameters(leafParameters, [ BigInt(options.chainId), getAddress(options.distributor), options.campaignId, getAddress(options.recipient), options.cumulativeAmount, ]), 'bytes', ), ) } export declare namespace rewardLeaf { /** Domain and recipient inputs for one leaf. */ type Options = { /** Zero-padded EarnVault campaign id. */ campaignId: Hex /** Tempo chain containing the distributor. */ chainId: number /** Cumulative EarnShare amount. */ cumulativeAmount: bigint /** EarnShare distributor address. */ distributor: Address /** Committed recipient. */ recipient: Address } } type Tree = { nodes: Hex[]; positions: number[] } function makeTree(leaves: readonly Hex[]): Tree { const sorted = leaves .map((hash, index) => ({ hash, index })) .sort((left, right) => left.hash.toLowerCase().localeCompare(right.hash.toLowerCase())) const nodes = Array.from({ length: 2 * leaves.length - 1 }) const positions = Array.from({ length: leaves.length }) for (const [index, leaf] of sorted.entries()) { const position = nodes.length - 1 - index nodes[position] = leaf.hash positions[leaf.index] = position } for (let index = nodes.length - 1 - leaves.length; index >= 0; index -= 1) nodes[index] = nodeHash(nodes[2 * index + 1]!, nodes[2 * index + 2]!) return { nodes, positions } } function nodeHash(left: Hex, right: Hex): Hex { return keccak256( concatHex(left.toLowerCase() < right.toLowerCase() ? [left, right] : [right, left]), ) } function treeProof(tree: Tree, leafIndex: number): Hex[] { const result: Hex[] = [] let index = tree.positions[leafIndex]! while (index > 0) { const sibling = index % 2 === 0 ? index - 1 : index + 1 result.push(tree.nodes[sibling]!) index = Math.floor((index - 1) / 2) } return result }