import * as fs from 'node:fs' import { GenericContainer, Network, type StartedNetwork, type StartedTestContainer, Wait, } from 'testcontainers' import { type Address, encodeAbiParameters, encodeEventTopics, parseAbi, zeroAddress, zeroHash, } from 'viem' import * as TestApp from '../../../../test/App.js' import * as TestEarn from '../../../../test/Earn.js' import * as Runtime from '../../../../test/runtime.js' import * as Cursor from '../../../internal/Cursor.js' import * as Earn from './earn.js' const abi = parseAbi([ 'event EarnStackDeployed(address indexed earnVault,address indexed earnShare,address indexed earnFees,address engine,address asset,address owner,bytes32 deploymentId,address emergencyGuardian,address asyncJanitor,uint8 migrationMode,bytes32 earnShareSalt,bytes32 controlConfigHash,bytes32 feeConfigHash,bytes32 earnFeesSalt)', ]) const share = '0x20c00000000000000000000075d0f7d57b571b8a' const asset = '0x20c0000000000000000000000000000000000001' const factory = '0xb4944264260a6412351d64653f65d2c27d9cb038' const foreignFactory = '0x5b972d2490d06e436982fab77612fd9987f097d0' const attacker = '0x1111111111111111111111111111111111111111' let url: string describe.runIf(Runtime.get().mode === 'testnet')('Earn discovery provenance', () => { let app: ReturnType let clickhouse: StartedTestContainer let indexer: StartedTestContainer let network: StartedNetwork let postgres: StartedTestContainer beforeAll(async () => { // Keep indexer storage disposable and use the same pinned images as the dev stack. const compose = fs.readFileSync( new URL('../../../../docker-compose.yml', import.meta.url), 'utf8', ) const image = compose.match(/TEMPO_API_TIDX_IMAGE:-([^}]+)}/)?.[1] const image_clickhouse = compose.match(/TEMPO_API_CLICKHOUSE_IMAGE:-([^}]+)}/)?.[1] const image_postgres = compose.match(/TEMPO_API_POSTGRES_IMAGE:-([^}]+)}/)?.[1] if (!image || !image_clickhouse || !image_postgres) throw new Error('Missing pinned database images.') network = await new Network().start() postgres = await new GenericContainer(image_postgres) .withEnvironment({ POSTGRES_PASSWORD: 'postgres' }) .withNetwork(network) .withNetworkAliases('postgres') .withExposedPorts(5432) .withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2)) .start() clickhouse = await new GenericContainer(image_clickhouse) .withEnvironment({ CLICKHOUSE_PASSWORD: 'clickhouse', CLICKHOUSE_USER: 'clickhouse' }) .withNetwork(network) .withNetworkAliases('clickhouse') .withExposedPorts(8123) .withWaitStrategy(Wait.forHttp('/ping', 8123)) .start() url = `http://${clickhouse.getHost()}:${clickhouse.getMappedPort(8123)}` indexer = await new GenericContainer(image) .withPlatform('linux/amd64') .withNetwork(network) .withCopyContentToContainer([ { content: ` [http] enabled = true port = 8080 bind = "0.0.0.0" [prometheus] enabled = false [[chains]] name = "earn-discovery" chain_id = 42431 rpc_url = "http://127.0.0.1:1" pg_url = "postgres://postgres:postgres@postgres:5432/postgres" backfill = false [chains.clickhouse] enabled = true url = "http://clickhouse:8123" user = "clickhouse" password_env = "CLICKHOUSE_PASSWORD" repair_derived_on_startup = false `, target: '/tmp/config.toml', }, ]) .withEnvironment({ CLICKHOUSE_PASSWORD: 'clickhouse' }) .withCommand(['up', '--config', '/tmp/config.toml']) .withExposedPorts(8080) .withWaitStrategy(Wait.forListeningPorts()) .withStartupTimeout(120_000) .start() await vi.waitFor( async () => { const response = await query( "SELECT count() FROM system.tables WHERE database = 'tidx_42431' AND name = 'address_holder_deltas'", ) expect(response.trim()).toBe('1') }, { timeout: 60_000 }, ) app = TestApp.create({ auth: false, cache: { edge: false }, tidx: { baseUrl: `http://${indexer.getHost()}:${indexer.getMappedPort(8080)}` }, }) }) afterAll(async () => { await indexer?.stop() await clickhouse?.stop() await postgres?.stop() await network?.stop() }) beforeEach(async () => { await query('TRUNCATE TABLE tidx_42431.logs') await query('TRUNCATE TABLE tidx_42431.address_holder_deltas') await insert({ address: factory, blockNumber: 100, vault: TestEarn.activeEarningsVaultAddress }) await query(`INSERT INTO tidx_42431.address_holder_deltas (block_num, block_timestamp, tx_hash, log_idx, holder, token, leg, balance_delta) VALUES (100, '2026-01-01 00:00:00', '${zeroHash}', 1, '${TestEarn.activeEarningsAddress}', '${share}', 1, 1)`) }) test('preserves held positions after a newer forged share binding', async () => { const path = `/v1/earn/addresses/${TestEarn.activeEarningsAddress}/positions?chainId=42431` const before = await app.request(path) expect(before.status, await before.clone().text()).toBe(200) const original = await TestApp.json(before, Earn.schema.getEarnAddressPositions.Response) expect(original.data.map((position) => position.vaultAddress)).toEqual([ TestEarn.activeEarningsVaultAddress, ]) await insert({ address: attacker, blockNumber: 101, share: zeroAddress, vault: TestEarn.activeEarningsVaultAddress, }) const after = await app.request(path) expect(after.status, await after.clone().text()).toBe(200) const result = await TestApp.json(after, Earn.schema.getEarnAddressPositions.Response) expect(result.data.map((position) => position.vaultAddress)).toEqual([ TestEarn.activeEarningsVaultAddress, ]) expect(BigInt(result.data[0]!.shareAmount.amount)).toBeGreaterThan(0n) }) test.each([attacker, foreignFactory] as const)( 'rejects deployment logs from %s before filtering and pagination', async (address) => { await insert({ address, asset: zeroAddress, blockNumber: 101, vault: TestEarn.activeEarningsVaultAddress, }) await insert({ address, blockNumber: 102, vault: TestEarn.vaultAddress }) const cursor = Cursor.encode([101, 0]) const response = await app.request( `/v1/earn/vaults?chainId=42431&asset=${asset}&cursor=${encodeURIComponent(cursor)}&limit=5`, ) expect(response.status, await response.clone().text()).toBe(200) const result = await TestApp.json(response, Earn.schema.getEarnVaults.Response) expect({ ids: result.data.map((vault) => vault.id), nextCursor: result.nextCursor }).toEqual({ ids: [TestEarn.activeEarningsVaultAddress], nextCursor: null, }) const unfiltered = await app.request('/v1/earn/vaults?chainId=42431&limit=5') expect(unfiltered.status).toBe(200) const page = await TestApp.json(unfiltered, Earn.schema.getEarnVaults.Response) expect({ ids: page.data.map((vault) => vault.id), nextCursor: page.nextCursor }).toEqual({ ids: [TestEarn.activeEarningsVaultAddress], nextCursor: null, }) }, ) test('returns no indexed discoveries on an unconfigured chain', async () => { const app = TestApp.create({ auth: false, defaultChainId: 1337, rpc: { url: 'http://127.0.0.1:1' }, supportedChainIds: [1337], tidx: { baseUrl: 'http://127.0.0.1:1' }, }) for (const path of [ '/v1/earn/vaults', `/v1/earn/addresses/${TestEarn.activeEarningsAddress}/positions`, ]) { const response = await app.request(path) expect(response.status, await response.clone().text()).toBe(200) expect(await response.json()).toEqual({ data: [], nextCursor: null }) } }) }) async function query(sql: string) { const response = await fetch(url, { body: sql, headers: { Authorization: `Basic ${btoa('clickhouse:clickhouse')}` }, method: 'POST', }) const body = await response.text() if (!response.ok) throw new Error(body) return body } async function insert(options: insert.Options) { const topics = encodeEventTopics({ abi, args: { earnFees: zeroAddress, earnShare: options.share ?? share, earnVault: options.vault }, eventName: 'EarnStackDeployed', }) const [selector, topic1, topic2, topic3] = topics if ( typeof selector !== 'string' || typeof topic1 !== 'string' || typeof topic2 !== 'string' || typeof topic3 !== 'string' ) throw new Error('Invalid deployment event topics.') const data = encodeAbiParameters( [ { type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'bytes32' }, { type: 'address' }, { type: 'address' }, { type: 'uint8' }, { type: 'bytes32' }, { type: 'bytes32' }, { type: 'bytes32' }, { type: 'bytes32' }, ], [ zeroAddress, options.asset ?? asset, zeroAddress, zeroHash, zeroAddress, zeroAddress, 0, zeroHash, zeroHash, zeroHash, zeroHash, ], ) await query(`INSERT INTO tidx_42431.logs (block_num, block_timestamp, log_idx, tx_idx, tx_hash, address, selector, topic0, topic1, topic2, topic3, data) VALUES (${options.blockNumber}, '2026-01-01 00:00:00', 0, 0, '${zeroHash}', '${options.address}', '${selector}', '${selector}', '${topic1}', '${topic2}', '${topic3}', '${data}')`) } declare namespace insert { type Options = { address: Address asset?: Address | undefined blockNumber: number share?: Address | undefined vault: Address } }