diff --git a/src/routes/holders/evm.sql b/src/routes/holders/evm.sql index 14b5d44c..f897c7d2 100644 --- a/src/routes/holders/evm.sql +++ b/src/routes/holders/evm.sql @@ -1,25 +1,37 @@ -/* get the latest balance for each account */ -WITH balances AS ( - SELECT address, contract, balance, timestamp, block_num - FROM {db_balances:Identifier}.erc20_balances FINAL - WHERE contract = {contract:String} AND balance > 0 - ORDER BY balance DESC, address - LIMIT {limit:UInt64} - OFFSET {offset:UInt64} +/* Materialize top-N balances once into a row array, then expand via arrayJoin. + The address-array is reused to pre-filter the contracts lookup via primary key, + avoiding the full-table hash build a plain JOIN would trigger. */ +WITH rows_arr AS ( + SELECT groupArray(tuple(address, balance, timestamp, block_num)) AS r + FROM ( + SELECT address, balance, timestamp, block_num + FROM {db_balances:Identifier}.erc20_balances FINAL + WHERE contract = {contract:String} AND balance > 0 + ORDER BY balance DESC, address + LIMIT {limit:UInt64} OFFSET {offset:UInt64} + ) ) SELECT /* timestamps */ - b.timestamp AS last_update, - b.block_num AS last_update_block_num, - toUnixTimestamp(b.timestamp) AS last_update_timestamp, + t.3 AS last_update, + t.4 AS last_update_block_num, + toUnixTimestamp(t.3) AS last_update_timestamp, /* identifiers */ - address, - contract, + t.1 AS address, + {contract:String} AS contract, /* amounts */ - toString(b.balance) AS amount, - b.balance / pow(10, m.decimals) AS value, + toString(t.2) AS amount, + t.2 / pow(10, m.decimals) AS value, + + /* holder type */ + toBool(t.1 IN ( + SELECT address FROM {db_contracts:Identifier}.contracts + WHERE address IN ( + SELECT arrayJoin(arrayMap(x -> x.1, (SELECT r FROM rows_arr))) + ) + )) AS is_contract, /* decimals and metadata */ nullIf(m.name, '') AS name, @@ -27,7 +39,9 @@ SELECT m.decimals AS decimals, /* network */ - {network:String} as network -FROM balances b + {network:String} AS network +FROM ( + SELECT arrayJoin((SELECT r FROM rows_arr)) AS t +) AS expanded LEFT JOIN metadata.metadata AS m FINAL ON m.network = {network:String} AND {contract:String} = m.contract -ORDER BY b.balance DESC, address +ORDER BY t.2 DESC, t.1 diff --git a/src/routes/holders/evm.ts b/src/routes/holders/evm.ts index a42fe51e..9fa36665 100644 --- a/src/routes/holders/evm.ts +++ b/src/routes/holders/evm.ts @@ -37,6 +37,9 @@ const responseSchema = apiUsageResponseSchema.extend({ amount: z.string(), value: z.number(), + // -- holder type -- + is_contract: z.boolean(), + // -- contract -- name: z.string().nullable(), symbol: z.string().nullable(), @@ -73,6 +76,7 @@ const openapi = describeRoute( contract: '0xdac17f958d2ee523a2206206994597c13d831ec7', amount: '20000000000000000', value: 20000000000, + is_contract: false, name: 'Tether USD', symbol: 'USDT', decimals: 6, @@ -99,14 +103,16 @@ route.get('/', openapi, zValidator('query', querySchema, validatorHook), validat const params = c.req.valid('query'); const dbBalances = config.balancesDatabases[params.network]; + const dbContracts = config.contractsDatabases[params.network]; - if (!dbBalances) { + if (!dbBalances || !dbContracts) { return c.json({ error: `Network not found: ${params.network}` }, 400); } const response = await makeUsageQueryJson(c, [query], { ...params, db_balances: dbBalances.database, + db_contracts: dbContracts.database, }); return handleUsageQueryError(c, response); }); diff --git a/src/routes/holders/evm_native.sql b/src/routes/holders/evm_native.sql index 9cbe08c8..ca620b46 100644 --- a/src/routes/holders/evm_native.sql +++ b/src/routes/holders/evm_native.sql @@ -21,28 +21,44 @@ addresses AS ( SELECT address FROM {db_balances:Identifier}.native_balances WHERE balance >= (SELECT * FROM cutoff) ), -/* get the latest balance for each account */ -balances AS ( - SELECT address, argMax(balance, b.block_num) as balance, max(b.timestamp) as timestamp, max(block_num) as block_num - FROM {db_balances:Identifier}.native_balances b - WHERE address IN (SELECT address FROM addresses) - GROUP BY address - ORDER BY balance DESC, address - LIMIT {limit:UInt64} - OFFSET {offset:UInt64} +/* Materialize top-N balances once into a row array, then expand via arrayJoin. + The address-array is reused to pre-filter the contracts lookup via primary key, + avoiding the full-table hash build a plain JOIN would trigger. */ +rows_arr AS ( + SELECT groupArray(tuple(address, balance, timestamp, block_num)) AS r + FROM ( + SELECT + address, + argMax(balance, b.block_num) AS balance, + max(b.timestamp) AS timestamp, + max(block_num) AS block_num + FROM {db_balances:Identifier}.native_balances b + WHERE address IN (SELECT address FROM addresses) + GROUP BY address + ORDER BY balance DESC, address + LIMIT {limit:UInt64} OFFSET {offset:UInt64} + ) ) SELECT /* timestamps */ - b.timestamp AS last_update, - b.block_num AS last_update_block_num, - toUnixTimestamp(b.timestamp) AS last_update_timestamp, + t.3 AS last_update, + t.4 AS last_update_block_num, + toUnixTimestamp(t.3) AS last_update_timestamp, /* identifiers */ - address, + t.1 AS address, /* amounts */ - toString(b.balance) AS amount, - b.balance / pow(10, m.decimals) AS value, + toString(t.2) AS amount, + t.2 / pow(10, m.decimals) AS value, + + /* holder type */ + toBool(t.1 IN ( + SELECT address FROM {db_contracts:Identifier}.contracts + WHERE address IN ( + SELECT arrayJoin(arrayMap(x -> x.1, (SELECT r FROM rows_arr))) + ) + )) AS is_contract, /* decimals and metadata */ nullIf(m.name, '') AS name, @@ -50,8 +66,10 @@ SELECT m.decimals AS decimals, /* network */ - {network:String} as network -FROM balances b + {network:String} AS network +FROM ( + SELECT arrayJoin((SELECT r FROM rows_arr)) AS t +) AS expanded LEFT JOIN metadata.metadata AS m FINAL ON m.network = {network:String} AND '0x0000000000000000000000000000000000000000' = m.contract -ORDER BY b.balance DESC, address +ORDER BY t.2 DESC, t.1 SETTINGS use_skip_indexes_for_top_k = 1, use_top_k_dynamic_filtering = 1 diff --git a/src/routes/holders/evm_native.ts b/src/routes/holders/evm_native.ts index c2eacab4..acdde8bc 100644 --- a/src/routes/holders/evm_native.ts +++ b/src/routes/holders/evm_native.ts @@ -32,6 +32,9 @@ const responseSchema = apiUsageResponseSchema.extend({ amount: z.string(), value: z.number(), + // -- holder type -- + is_contract: z.boolean(), + // -- contract -- name: z.string().nullable(), symbol: z.string().nullable(), @@ -67,6 +70,7 @@ const openapi = describeRoute( address: '0x00000000219ab540356cbb839cbe05303d7705fa', amount: '78761803578844096172899779', value: 78761803.5788441, + is_contract: true, name: 'Ethereum', symbol: 'ETH', decimals: 18, @@ -89,8 +93,9 @@ route.get('/', openapi, zValidator('query', querySchema, validatorHook), validat const params = c.req.valid('query'); const dbBalances = config.balancesDatabases[params.network]; + const dbContracts = config.contractsDatabases[params.network]; - if (!dbBalances) { + if (!dbBalances || !dbContracts) { return c.json({ error: `Network not found: ${params.network}` }, 400); } @@ -100,6 +105,7 @@ route.get('/', openapi, zValidator('query', querySchema, validatorHook), validat { ...params, db_balances: dbBalances.database, + db_contracts: dbContracts.database, }, { clickhouse_settings: { diff --git a/src/routes/routes.perf.spec.ts b/src/routes/routes.perf.spec.ts index db0c6ad2..883643a2 100644 --- a/src/routes/routes.perf.spec.ts +++ b/src/routes/routes.perf.spec.ts @@ -406,8 +406,8 @@ const PERF_ROUTES: PerfRoute[] = [ lookup('/v1/evm/balances/historical/native', 'evm', ['balances'], `address=${EVM_ADDRESS_VITALIK_EXAMPLE}`), lookup('/v1/svm/balances', 'svm', ['balances'], `owner=${SVM_OWNER_USER_EXAMPLE}`), lookup('/v1/svm/balances/native', 'svm', ['balances'], `address=${SVM_ADDRESS_OWNER_EXAMPLE}`), - lookup('/v1/evm/holders', 'evm', ['balances'], (n) => `contract=${getEvmExamples(n).contract}`), - lookup('/v1/evm/holders/native', 'evm', ['balances']), + lookup('/v1/evm/holders', 'evm', ['balances', 'contracts'], (n) => `contract=${getEvmExamples(n).contract}`), + lookup('/v1/evm/holders/native', 'evm', ['balances', 'contracts']), lookup('/v1/svm/holders', 'svm', ['balances'], `mint=${SVM_MINT_WSOL_EXAMPLE}`), lookup('/v1/evm/dexes', 'evm', ['dex'], '', BUDGET.heavyLookup), lookup('/v1/svm/dexes', 'svm', ['dex']),