import { getAddress, zeroAddress, type Address } from 'viem' import * as Schema from '../Schema.js' import type * as Tidx from '../Tidx.js' import * as Value from '../Value.js' import type * as Campaigns from './Campaigns.js' import type * as Projection from './Projection.js' const depositedSignature = 'event Deposited(address indexed caller,address indexed receiver,uint256 assets,uint256 earnShares)' const contributedSignature = 'event Contributed(address indexed caller,uint256 assets,uint256 engineShares,uint256 anchorEngineShares,uint256 anchorEarnShares)' const accountBatchSize = 50 const accountQueryConcurrency = 8 const eventPageSize = 10_000 const maximumRewardEvents = 250_000 /** Resolves the final indexed block strictly before one interval boundary. */ export async function boundary(tidx: Tidx.Client, timestamp: number): Promise { const result = await tidx.fetch({ engine: 'clickhouse', query: boundaryQuery(timestamp), }) const row = result.rows[0] const blockNumber = Value.toNumber(row?.['num']) const indexedAt = Value.toIsoDateTime(row?.['timestamp']) const indexedThrough = Value.toIsoDateTime(row?.['indexed_through']) if (blockNumber === undefined || indexedAt === undefined || indexedThrough === undefined) throw new Error(`TIDX has not indexed reward boundary ${timestamp}.`) if (Date.parse(indexedThrough) < timestamp * 1_000) throw new Error(`TIDX has not indexed reward boundary ${timestamp}.`) return { blockNumber, timestamp: Math.floor(Date.parse(indexedAt) / 1_000) } } /** Builds the strict TIDX block-boundary query used by reward accounting. */ export function boundaryQuery(timestamp: number): string { const cutoff = new Date(timestamp * 1_000).toISOString().replace('T', ' ').replace(/Z$/, '') return ` SELECT num, timestamp, (SELECT max(timestamp) FROM blocks) AS indexed_through FROM blocks WHERE timestamp < '${cutoff}' ORDER BY timestamp DESC, num DESC LIMIT 1 ` } export declare namespace boundary { /** Indexed block anchoring one accounting boundary. */ type Result = { /** Indexed Tempo block number. */ blockNumber: number /** Indexed block timestamp. */ timestamp: number } } /** Streams canonically ordered deposits and transfers involving tracked reward accounts. */ export async function* accountEventPages( tidx: Tidx.Client, options: accountEventPages.Options, ): AsyncGenerator { const accounts = [...new Set(options.accounts.map(normalizeAddress))] if (accounts.length === 0) return const batches = chunk(accounts, accountBatchSize) const pageSize = Math.max(1, Math.floor(eventPageSize / batches.length)) const streams = batches.map((accounts) => accountBatch(tidx, { ...options, accounts, pageSize })) const heads = await concurrentMap(streams, accountQueryConcurrency, (stream) => stream.next()) let page: Projection.Event[] = [] let previous: Campaigns.EventCursor | undefined while (true) { let index = -1 for (const [candidate, head] of heads.entries()) { if (head.done) continue if (index === -1 || compareCursors(head.value.cursor, heads[index]!.value.cursor) < 0) index = candidate } if (index === -1) break const event = heads[index]!.value if (!previous || !sameCursor(previous, event.cursor)) { page.push(event) previous = event.cursor } heads[index] = await streams[index]!.next() if (page.length === eventPageSize) { yield page page = [] } } if (page.length > 0) yield page } /** Builds the tracked-account event query used by reward projections. */ export function accountEventsQuery(options: accountEventsQuery.Options): string { const accounts = [...new Set(options.accounts.map(normalizeAddress))] if (accounts.length === 0) throw new Error('Reward account event query requires an account.') const addresses = accounts.map((account) => `'${account}'`).join(', ') const after = eventAfter(options.after) const share = normalizeAddress(options.earnShare) const vault = normalizeAddress(options.earnVault) return ` SELECT kind, block_num, tx_idx, log_idx, block_timestamp, sender, recipient, amount, assets, earn_shares FROM ( SELECT 'deposit' AS kind, block_num, tx_idx, log_idx, block_timestamp, '' AS sender, receiver AS recipient, '0' AS amount, toString(toUInt256(assets)) AS assets, toString(toUInt256("earnShares")) AS earn_shares FROM Deposited WHERE lower(address) = '${vault}' AND lower(receiver) IN (${addresses}) AND toUInt256(assets) > 0 AND toUInt256("earnShares") > 0 AND block_num <= ${options.throughBlock} ${after} UNION ALL SELECT 'transfer' AS kind, block_num, tx_idx, log_idx, block_timestamp, "from" AS sender, "to" AS recipient, toString(toUInt256(amount)) AS amount, '0' AS assets, '0' AS earn_shares FROM token_transfers WHERE lower(token) = '${share}' AND (lower("from") IN (${addresses}) OR lower("to") IN (${addresses})) AND lower("from") != lower("to") AND toUInt256(amount) > 0 AND block_num <= ${options.throughBlock} ${after} ) ORDER BY block_num ASC, tx_idx ASC, log_idx ASC LIMIT ${options.pageSize ?? eventPageSize} ` } /** Reads distinct EarnShare supply-change blocks in canonical order. */ export async function capitalChanges( tidx: Tidx.Client, options: capitalChanges.Options, ): Promise { const entries: boundary.Result[] = [] let afterBlock = options.afterBlock while (true) { const result = await tidx.fetch({ engine: 'clickhouse', query: capitalChangesQuery({ ...options, afterBlock }), }) const page = result.rows.map(parseCapitalChange) entries.push(...page) if (entries.length > maximumRewardEvents) throw new Error(`Reward capital history exceeds ${maximumRewardEvents} entries.`) if (page.length < eventPageSize) return entries const next = page.at(-1)!.blockNumber if (afterBlock === next) throw new Error('TIDX reward capital block did not advance.') afterBlock = next } } /** Builds the supply-change block query used by target-yield accounting. */ export function capitalChangesQuery(options: capitalChanges.Options): string { const share = normalizeAddress(options.earnShare) return ` SELECT block_num, any(block_timestamp) AS block_timestamp FROM token_transfers WHERE lower(token) = '${share}' AND (lower("from") = '${zeroAddress}' OR lower("to") = '${zeroAddress}') AND toUInt256(amount) > 0 AND block_num > ${options.afterBlock} AND block_num <= ${options.throughBlock} GROUP BY block_num ORDER BY block_num ASC LIMIT ${eventPageSize} ` } export declare namespace capitalChanges { /** Supply-change block range. */ type Options = { /** Exclusive opening block. */ afterBlock: number /** EarnShare token address. */ earnShare: Address /** Inclusive closing block. */ throughBlock: number } } /** Reads asset contributions that must be removed from measured organic growth. */ export async function contributions( tidx: Tidx.Client, options: contributions.Options, ): Promise { const entries: contributions.Entry[] = [] const vault = normalizeAddress(options.earnVault) let cursor: Campaigns.EventCursor | undefined while (true) { const after = cursor ? eventAfter(cursor) : `AND block_num > ${options.afterBlock}` const result = await tidx.fetch({ engine: 'clickhouse', query: ` SELECT DISTINCT block_num, tx_idx, log_idx, block_timestamp, toString(toUInt256(assets)) AS assets, toString(toUInt256("anchorEarnShares")) AS anchor_earn_shares FROM Contributed WHERE lower(address) = '${vault}' ${after} AND block_num <= ${options.throughBlock} ORDER BY block_num ASC, tx_idx ASC, log_idx ASC LIMIT ${eventPageSize} `, signatures: [contributedSignature], }) const page = result.rows.map(parseContribution) entries.push(...page) if (entries.length > maximumRewardEvents) throw new Error(`Reward contribution history exceeds ${maximumRewardEvents} entries.`) if (page.length < eventPageSize) return entries const next = page.at(-1)!.cursor if (cursor && sameCursor(cursor, next)) throw new Error('TIDX reward contribution cursor did not advance.') cursor = next } } export declare namespace contributions { /** One canonical vault contribution. */ type Entry = { /** Contributed base-asset units. */ assets: bigint /** Canonical event position. */ cursor: Campaigns.EventCursor /** Fee-inclusive EarnShare supply after the contribution. */ earnShareSupply: bigint /** Contribution block timestamp. */ timestamp: number } /** Contribution-range selector. */ type Options = { /** Exclusive opening block. */ afterBlock: number /** EarnVault emitting contributions. */ earnVault: Address /** Inclusive closing block. */ throughBlock: number } } function sameCursor(left: Campaigns.EventCursor, right: Campaigns.EventCursor): boolean { return ( left.blockNumber === right.blockNumber && left.transactionIndex === right.transactionIndex && left.logIndex === right.logIndex ) } export declare namespace accountEventPages { /** Tracked-account event range. */ type Options = { /** Last applied event, omitted for campaign bootstrap. */ after?: Campaigns.EventCursor | undefined /** Reward accounts whose deposits and transfers affect the projection. */ accounts: readonly Address[] /** EarnShare token address. */ earnShare: Address /** EarnVault address. */ earnVault: Address /** Inclusive closing block. */ throughBlock: number } } export declare namespace accountEventsQuery { /** Tracked-account query options. */ type Options = accountEventPages.Options & { /** Maximum rows returned by this query. */ pageSize?: number | undefined } } /** Streams one tracked account's deposits without scanning unrelated transfer history. */ export async function* accountDepositPages( tidx: Tidx.Client, options: accountDepositPages.Options, ): AsyncGenerator { let cursor = options.after while (true) { const result = await tidx.fetch({ engine: 'clickhouse', query: accountDepositsQuery({ ...options, ...(cursor ? { after: cursor } : {}) }), signatures: [depositedSignature], }) const page = result.rows.map(parseEvent) if (page.length > 0) yield page if (page.length < eventPageSize) return const next = page.at(-1)!.cursor if (cursor && sameCursor(cursor, next)) throw new Error('TIDX reward deposit cursor did not advance.') cursor = next } } /** Builds one tracked account's deposit-only reconciliation query. */ export function accountDepositsQuery(options: accountDepositPages.Options): string { const account = normalizeAddress(options.account) const after = eventAfter(options.after) const vault = normalizeAddress(options.earnVault) return ` SELECT DISTINCT 'deposit' AS kind, block_num, tx_idx, log_idx, block_timestamp, '' AS sender, receiver AS recipient, '0' AS amount, toString(toUInt256(assets)) AS assets, toString(toUInt256("earnShares")) AS earn_shares FROM Deposited WHERE lower(address) = '${vault}' AND lower(receiver) = '${account}' AND toUInt256(assets) > 0 AND toUInt256("earnShares") > 0 AND block_num <= ${options.throughBlock} ${after} ORDER BY block_num ASC, tx_idx ASC, log_idx ASC LIMIT ${eventPageSize} ` } export declare namespace accountDepositPages { /** One account's deposit history range. */ type Options = { /** Reward account receiving deposits. */ account: Address /** Last applied deposit, omitted for campaign bootstrap. */ after?: Campaigns.EventCursor | undefined /** EarnVault emitting deposits. */ earnVault: Address /** Inclusive closing block. */ throughBlock: number } } async function* accountBatch( tidx: Tidx.Client, options: accountEventsQuery.Options & { pageSize: number }, ): AsyncGenerator { let cursor = options.after while (true) { const result = await tidx.fetch({ engine: 'clickhouse', query: accountEventsQuery({ ...options, ...(cursor ? { after: cursor } : {}) }), signatures: [depositedSignature], }) const page = result.rows.map(parseEvent) for (const event of page) yield event if (page.length < options.pageSize) return const next = page.at(-1)!.cursor if (cursor && sameCursor(cursor, next)) throw new Error('TIDX reward event cursor did not advance.') cursor = next } } function chunk(values: readonly value[], size: number): readonly (readonly value[])[] { return Array.from({ length: Math.ceil(values.length / size) }, (_, index) => values.slice(index * size, (index + 1) * size), ) } async function concurrentMap( values: readonly value[], concurrency: number, fn: (value: value) => Promise, ): Promise { const results: result[] = [] let index = 0 await Promise.all( Array.from({ length: Math.min(concurrency, values.length) }, async () => { while (index < values.length) { const current = index++ results[current] = await fn(values[current]!) } }), ) return results } function eventAfter(cursor: Campaigns.EventCursor | undefined): string { return cursor ? `AND (block_num > ${cursor.blockNumber} OR (block_num = ${cursor.blockNumber} AND (tx_idx > ${cursor.transactionIndex} OR (tx_idx = ${cursor.transactionIndex} AND log_idx > ${cursor.logIndex}))))` : '' } function normalizeAddress(address: Address): Address { return Schema.Address.parse(address).toLowerCase() as Address } function compareCursors(left: Campaigns.EventCursor, right: Campaigns.EventCursor): number { return ( left.blockNumber - right.blockNumber || left.transactionIndex - right.transactionIndex || left.logIndex - right.logIndex ) } function parseCapitalChange(row: Record): boundary.Result { const blockNumber = Value.toNumber(row['block_num']) const indexedAt = Value.toIsoDateTime(row['block_timestamp']) if (blockNumber === undefined || indexedAt === undefined) throw new Error('TIDX returned a malformed reward capital change.') return { blockNumber, timestamp: Math.floor(Date.parse(indexedAt) / 1_000) } } function parseEvent(row: Record): Projection.Event { const blockNumber = Value.toNumber(row['block_num']) const transactionIndex = Value.toNumber(row['tx_idx']) const logIndex = Value.toNumber(row['log_idx']) const indexedAt = Value.toIsoDateTime(row['block_timestamp']) if ( blockNumber === undefined || transactionIndex === undefined || logIndex === undefined || indexedAt === undefined ) throw new Error('TIDX returned a malformed reward event position.') const cursor = { blockNumber, logIndex, transactionIndex } const timestamp = Math.floor(Date.parse(indexedAt) / 1_000) if (row['kind'] === 'deposit') { const assets = Value.toIntegerString(row['assets']) const earnShares = Value.toIntegerString(row['earn_shares']) const recipient = Value.toText(row['recipient']) if (assets === undefined || earnShares === undefined || recipient === undefined) throw new Error('TIDX returned a malformed Earn deposit.') return { assets: BigInt(assets), cursor, earnShares: BigInt(earnShares), kind: 'deposit', recipient: getAddress(recipient), timestamp, } } const amount = Value.toIntegerString(row['amount']) const from = Value.toText(row['sender']) const to = Value.toText(row['recipient']) if (amount === undefined || from === undefined || to === undefined) throw new Error('TIDX returned a malformed EarnShare transfer.') return { amount: BigInt(amount), cursor, from: getAddress(from), kind: 'transfer', timestamp, to: getAddress(to), } } function parseContribution(row: Record): contributions.Entry { const anchorEarnShares = Value.toIntegerString(row['anchor_earn_shares']) const assets = Value.toIntegerString(row['assets']) const blockNumber = Value.toNumber(row['block_num']) const indexedAt = Value.toIsoDateTime(row['block_timestamp']) const logIndex = Value.toNumber(row['log_idx']) const transactionIndex = Value.toNumber(row['tx_idx']) if ( anchorEarnShares === undefined || assets === undefined || blockNumber === undefined || indexedAt === undefined || logIndex === undefined || transactionIndex === undefined ) throw new Error('TIDX returned a malformed Earn contribution.') return { assets: BigInt(assets), cursor: { blockNumber, logIndex, transactionIndex }, earnShareSupply: BigInt(anchorEarnShares), timestamp: Math.floor(Date.parse(indexedAt) / 1_000), } }