From 331abaf72806a1bb72528506b677e12ef5b79f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vuka=C5=A1in=20Manojlovi=C4=87?= Date: Mon, 13 Jul 2026 11:47:09 +0000 Subject: [PATCH 1/3] Add Uniswap v3 and v4 on Monad --- src/adaptors/uniswap-v3/index.js | 4 +- src/adaptors/uniswap-v3/onchain.js | 155 ++++++++++++--------- src/adaptors/uniswap-v4/index.js | 210 +++++++++++++++++++++++++---- 3 files changed, 278 insertions(+), 91 deletions(-) diff --git a/src/adaptors/uniswap-v3/index.js b/src/adaptors/uniswap-v3/index.js index b236a43105..bc646adb41 100644 --- a/src/adaptors/uniswap-v3/index.js +++ b/src/adaptors/uniswap-v3/index.js @@ -299,8 +299,8 @@ const main = async (timestamp = null) => { ); }) ); - const [data, bobPools] = await Promise.all([dataPromise, getOnchainPools()]); - data.push(bobPools); + const [data, onchainPools] = await Promise.all([dataPromise, getOnchainPools()]); + data.push(onchainPools); const pools = await addMerklRewardApy( data diff --git a/src/adaptors/uniswap-v3/onchain.js b/src/adaptors/uniswap-v3/onchain.js index eda48c596a..92ee8e2c95 100644 --- a/src/adaptors/uniswap-v3/onchain.js +++ b/src/adaptors/uniswap-v3/onchain.js @@ -10,6 +10,12 @@ const chains = { blockTime: 2, ui: 'oku', }, + monad: { + factory: '0x204faca1764b154221e35c0d20abb3c525710498', + fromBlock: 29255827, + blockTime: 0.4, + ui: 'uniswap', + }, }; const EVENTS = { @@ -17,6 +23,53 @@ const EVENTS = { Swap: 'event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)', }; +const CONCURRENT_POOL_SCANS = 5; + +const sumSwapUsd = (swapLogs, pool, tokenDecimals, prices) => { + const toUsd = (amount, token) => + BigNumber(amount) + .times(10 ** (18 - tokenDecimals[token])) + .times(prices.pricesByAddress[token]) + .div(1e18); + + const sumValues = (values) => + values.reduce((total, value) => total + value, 0n); + + const amounts0 = swapLogs + .map((log) => BigInt(log.args.amount0)) + .filter((amount) => amount > 0n); + const amounts1 = swapLogs + .map((log) => BigInt(log.args.amount1)) + .filter((amount) => amount > 0n); + + const fees0 = sumValues( + amounts0.map((amount) => (amount * pool.fee) / 1_000_000n) + ); + const fees1 = sumValues( + amounts1.map((amount) => (amount * pool.fee) / 1_000_000n) + ); + + return { + feeUsd: toUsd(fees0, pool.token0).plus(toUsd(fees1, pool.token1)), + volumeUsd: toUsd(sumValues(amounts0), pool.token0).plus( + toUsd(sumValues(amounts1), pool.token1) + ), + }; +}; + +const getPricesChunked = async (tokens, chain) => { + const pricesByAddress = {}; + const pricesBySymbol = {}; + + for (let start = 0; start < tokens.length; start += 100) { + const prices = await utils.getPrices(tokens.slice(start, start + 100), chain); + Object.assign(pricesByAddress, prices.pricesByAddress); + Object.assign(pricesBySymbol, prices.pricesBySymbol); + } + + return { pricesByAddress, pricesBySymbol }; +}; + const getPools = async (chain) => { const config = chains[chain]; const dataPools = []; @@ -32,16 +85,16 @@ const getPools = async (chain) => { }); const pools = poolCreatedLogs.map((log) => ({ - token0: log.args.token0, - token1: log.args.token1, - address: log.args.pool, + token0: log.args.token0.toLowerCase(), + token1: log.args.token1.toLowerCase(), + address: log.args.pool.toLowerCase(), fee: BigInt(log.args.fee), })); const tokens = getUniqueAddresses( pools.map((p) => p.token0).concat(pools.map((p) => p.token1)) ); - const prices = await utils.getPrices(tokens, chain); + const prices = await getPricesChunked(tokens, chain); const tokenDecimals = Object.fromEntries( ( @@ -95,6 +148,7 @@ const getPools = async (chain) => { pools[i].balance1 = BigNumber(token1Balances.output[i].output); }); + const pricedPools = []; for (const pool of pools) { if ( !prices.pricesByAddress[pool.token0] || @@ -103,88 +157,67 @@ const getPools = async (chain) => { continue; } - const swapLogs = await sdk.getEventLogs({ - chain, - target: pool.address, - eventAbi: EVENTS.Swap, - fromBlock: currentBlock.number - (24 * 3600) / config.blockTime, - toBlock: currentBlock.number, - }); - - const parsedSwapLogs = swapLogs.map((log) => log.args); - - const totalFee0 = parsedSwapLogs - .map((log) => BigInt(log.amount0)) - .filter((x) => x > 0n) - .reduce((sum, x) => sum + (x * pool.fee) / 1_000_000n, 0n); - const totalFee1 = parsedSwapLogs - .map((log) => BigInt(log.amount1)) - .filter((x) => x > 0n) - .reduce((sum, x) => sum + (x * pool.fee) / 1_000_000n, 0n); - - const feeValue0 = BigNumber(totalFee0) + const tvl0 = pool.balance0 .times(10 ** (18 - tokenDecimals[pool.token0])) .times(prices.pricesByAddress[pool.token0]) .div(1e18); - const feeValue1 = BigNumber(totalFee1) + const tvl1 = pool.balance1 .times(10 ** (18 - tokenDecimals[pool.token1])) .times(prices.pricesByAddress[pool.token1]) .div(1e18); - const feeValue = feeValue0.plus(feeValue1); - - const totalVolume0 = parsedSwapLogs - .map((log) => BigInt(log.amount0)) - .filter((x) => x > 0n) - .reduce((sum, x) => sum + x, 0n); - const totalVolume1 = parsedSwapLogs - .map((log) => BigInt(log.amount1)) - .filter((x) => x > 0n) - .reduce((sum, x) => sum + x, 0n); + const tvl = tvl0.plus(tvl1); - const volumeValue0 = BigNumber(totalVolume0) - .times(10 ** (18 - tokenDecimals[pool.token0])) - .times(prices.pricesByAddress[pool.token0]) - .div(1e18); - const volumeValue1 = BigNumber(totalVolume1) - .times(10 ** (18 - tokenDecimals[pool.token1])) - .times(prices.pricesByAddress[pool.token1]) - .div(1e18); + // skip dust pools before the expensive per-pool swap log scans + if (!(tvl.toNumber() >= utils.MIN_TVL_USD)) { + continue; + } - const volumeValue = volumeValue0.plus(volumeValue1); + pricedPools.push({ ...pool, tvl }); + } - const tvl0 = pool.balance0 - .times(10 ** (18 - tokenDecimals[pool.token0])) - .times(prices.pricesByAddress[pool.token0]) - .div(1e18); - const tvl1 = pool.balance1 - .times(10 ** (18 - tokenDecimals[pool.token1])) - .times(prices.pricesByAddress[pool.token1]) - .div(1e18); + const blocksPerDay = (24 * 3600) / config.blockTime; - const tvl = tvl0.plus(tvl1); + const scanPool = async (pool) => { + const swapLogs = await sdk.getEventLogs({ + chain, + target: pool.address, + eventAbi: EVENTS.Swap, + fromBlock: currentBlock.number - blocksPerDay, + toBlock: currentBlock.number, + }); - const apr = feeValue.div(tvl).times(100).times(365); - const apy = utils.aprToApy(apr.toNumber()); + const totals = sumSwapUsd(swapLogs, pool, tokenDecimals, prices); + const apr = totals.feeUsd.div(pool.tvl).times(100).times(365); const poolMeta = `${Number(pool.fee) / 1e4}%`; - dataPools.push({ + return { pool: pool.address, chain, project: 'uniswap-v3', poolMeta, symbol: [tokenSymbols[pool.token0], tokenSymbols[pool.token1]].join('-'), - tvlUsd: tvl.toNumber(), - apyBase: apy, + tvlUsd: pool.tvl.toNumber(), + apyBase: utils.aprToApy(apr.toNumber()), underlyingTokens: [pool.token0, pool.token1], url: config.ui === 'oku' ? `https://oku.trade/app/${chain}/liquidity/${pool.address}` : `https://app.uniswap.org/#/add/${pool.token0}/${pool.token1}/${pool.fee}?chain=${chain}`, - volumeUsd1d: volumeValue.toNumber(), - }); - } + volumeUsd1d: totals.volumeUsd.toNumber(), + }; + }; + + const scanQueue = [...pricedPools]; + const runScanWorker = async () => { + while (scanQueue.length > 0) { + dataPools.push(await scanPool(scanQueue.shift())); + } + }; + await Promise.all( + Array.from({ length: CONCURRENT_POOL_SCANS }, runScanWorker) + ); return dataPools; }; diff --git a/src/adaptors/uniswap-v4/index.js b/src/adaptors/uniswap-v4/index.js index 921366ee78..93ac91efaf 100644 --- a/src/adaptors/uniswap-v4/index.js +++ b/src/adaptors/uniswap-v4/index.js @@ -27,15 +27,22 @@ const chains = { ), }; +// Chains where the only allocated indexer prunes historical state, which +// breaks the block-offset queries used by topLvl. Volume for these chains +// comes from poolDayDatas at the latest block instead. +const dayDataChains = { + monad: sdk.graph.modifyEndpoint( + '6CQtx9W4b9Kn9cjznXJNLeTvLV1hbpxkaJZkbyXirJuz' + ), +}; + const DYNAMIC_FEE_FLAG = 0x800000; const PAGE_SIZE = 1000; const TVL_MIN = 50000; const SUSPECT_TVL_USD = 1e8; const MIN_VOLUME_TO_TVL_RATIO = 1e-5; -const queryWithSkip = (skip) => gql` - { - pools(first: ${PAGE_SIZE}, skip: ${skip}, orderBy: totalValueLockedUSD, orderDirection: desc, where: {totalValueLockedUSD_gte: ${TVL_MIN}}, block: {number: }) { +const POOL_FIELDS = ` id feeTier totalValueLockedUSD @@ -51,7 +58,11 @@ const queryWithSkip = (skip) => gql` symbol decimals id - } + }`; + +const queryWithSkip = (skip) => gql` + { + pools(first: ${PAGE_SIZE}, skip: ${skip}, orderBy: totalValueLockedUSD, orderDirection: desc, where: {totalValueLockedUSD_gte: ${TVL_MIN}}, block: {number: }) {${POOL_FIELDS} } } `; @@ -74,6 +85,35 @@ const fetchAllPools = async (url, block) => { const isDynamicFeePool = (feeTier) => Number(feeTier) === DYNAMIC_FEE_FLAG; +const formatPool = (chainString, p) => { + const isDynamic = isDynamicFeePool(p.feeTier); + + let poolMeta; + if (isDynamic) { + poolMeta = 'Dynamic fee (hook)'; + } else { + const feePercent = (Number(p.feeTier) / 1e4).toFixed(2); + poolMeta = `${feePercent}%`; + } + + const underlyingTokens = [p.token0.id, p.token1.id]; + const chain = chainString === 'avax' ? 'avalanche' : chainString; + + return { + pool: `${p.id}-${chainString}-uniswap-v4`, + chain: utils.formatChain(chainString), + project: 'uniswap-v4', + token: null, + poolMeta, + symbol: `${p.token0.symbol}-${p.token1.symbol}`, + tvlUsd: p.totalValueLockedUSD, + apyBase: p.apyBase, + underlyingTokens, + url: `https://app.uniswap.org/explore/pools/${chain}/${p.id}`, + volumeUsd1d: p.volumeUsd1d, + }; +}; + const hasInvalidTokenTvl = (pool) => Number(pool.totalValueLockedToken0) < 0 || Number(pool.totalValueLockedToken1) < 0; @@ -128,32 +168,141 @@ const topLvl = async (chainString, url, timestamp) => { }; }); - return dataNow.map((p) => { - const isDynamic = isDynamicFeePool(p.feeTier); + return dataNow.map((p) => formatPool(chainString, p)); + } catch (e) { + console.log(chainString, e); + return []; + } +}; + +const latestPoolsQuery = (skip) => gql` + { + pools(first: ${PAGE_SIZE}, skip: ${skip}, orderBy: id, where: {totalValueLockedUSD_gte: ${TVL_MIN}}) {${POOL_FIELDS} + } + } +`; + +const poolsByIdQuery = (ids) => gql` + { + pools(first: ${PAGE_SIZE}, where: {id_in: ${JSON.stringify(ids)}}) {${POOL_FIELDS} + } + } +`; + +const dayVolumesQuery = (dateGte, dateLte, skip) => gql` + { + poolDayDatas(first: ${PAGE_SIZE}, skip: ${skip}, orderBy: id, where: {date_gte: ${dateGte}, date_lte: ${dateLte}}) { + date + volumeUSD + pool { + id + } + } + } +`; + +const fetchLatestPools = async (url) => { + let allPools = []; + + for (let skip = 0; skip <= 5000; skip += PAGE_SIZE) { + const data = await request(url, latestPoolsQuery(skip)); + allPools = allPools.concat(data.pools ?? []); + if (!data.pools || data.pools.length < PAGE_SIZE) break; + } + + return allPools; +}; + +// returns { poolId: { volumeUSD1d, volumeUSD7d } } built from the last 7 full +// days of poolDayDatas; the most recent full day supplies volumeUSD1d +const fetchDayVolumes = async (url, previousDay) => { + const volumesByPoolId = {}; + const firstDay = previousDay - 6 * 86400; + + for (let skip = 0; skip <= 5000; skip += PAGE_SIZE) { + const data = await request(url, dayVolumesQuery(firstDay, previousDay, skip)); + + for (const dayData of data.poolDayDatas ?? []) { + const poolId = dayData.pool.id; + const volumes = volumesByPoolId[poolId] ?? { + volumeUSD1d: 0, + volumeUSD7d: 0, + }; - let poolMeta; - if (isDynamic) { - poolMeta = 'Dynamic fee (hook)'; - } else { - const feePercent = (Number(p.feeTier) / 1e4).toFixed(2); - poolMeta = `${feePercent}%`; + volumes.volumeUSD7d += Number(dayData.volumeUSD); + if (Number(dayData.date) === previousDay) { + volumes.volumeUSD1d += Number(dayData.volumeUSD); } - const underlyingTokens = [p.token0.id, p.token1.id]; - const chain = chainString === 'avax' ? 'avalanche' : chainString; + volumesByPoolId[poolId] = volumes; + } + + if (!data.poolDayDatas || data.poolDayDatas.length < PAGE_SIZE) break; + } + + return volumesByPoolId; +}; + +const topLvlDayData = async (chainString, url) => { + try { + // freshness assertion only; throws when the subgraph lags the chain + await utils.getBlocks(chainString, null, [url]); + + const previousDay = (Math.floor(Date.now() / 1000 / 86400) - 1) * 86400; + const [tvlPools, volumesByPoolId] = await Promise.all([ + fetchLatestPools(url), + fetchDayVolumes(url, previousDay), + ]); + + const knownPoolIds = new Set(tvlPools.map((pool) => pool.id)); + const missingPoolIds = Object.keys(volumesByPoolId).filter( + (poolId) => !knownPoolIds.has(poolId) + ); + + let dataNow = [...tvlPools]; + for (let start = 0; start < missingPoolIds.length; start += 100) { + const data = await request( + url, + poolsByIdQuery(missingPoolIds.slice(start, start + 100)) + ); + dataNow = dataNow.concat(data.pools ?? []); + } + + dataNow = dataNow + .filter((pool) => !hasInvalidTokenTvl(pool)) + .map((pool) => ({ + ...pool, + reserve0: pool.totalValueLockedToken0, + reserve1: pool.totalValueLockedToken1, + })); + + dataNow = await utils.tvl(dataNow, chainString); + dataNow = dataNow.filter((pool) => pool.totalValueLockedUSD >= TVL_MIN); + + return dataNow.map((pool) => { + const volumes = volumesByPoolId[pool.id]; + const volumeUSD1d = volumes?.volumeUSD1d ?? 0; + const volumeUSD7d = volumes?.volumeUSD7d ?? 0; + + const feeRate = isDynamicFeePool(pool.feeTier) + ? 0 + : Number(pool.feeTier) / 1e6; + const feeUSD1d = volumeUSD1d * feeRate; + const feeUSD7d = volumeUSD7d * feeRate; + + const toApy = (annualFeesUsd) => + pool.totalValueLockedUSD > 0 && annualFeesUsd > 0 + ? (annualFeesUsd * 100) / pool.totalValueLockedUSD + : 0; return { - pool: `${p.id}-${chainString}-uniswap-v4`, - chain: utils.formatChain(chainString), - project: 'uniswap-v4', - token: null, - poolMeta, - symbol: `${p.token0.symbol}-${p.token1.symbol}`, - tvlUsd: p.totalValueLockedUSD, - apyBase: p.apyBase, - underlyingTokens, - url: `https://app.uniswap.org/explore/pools/${chain}/${p.id}`, - volumeUsd1d: p.volumeUsd1d, + ...formatPool(chainString, { + ...pool, + apyBase: toApy(feeUSD1d * 365), + volumeUsd1d: volumeUSD1d, + }), + apyBase7d: toApy(feeUSD7d * 52), + volumeUsd7d: volumeUSD7d, }; }); } catch (e) { @@ -163,9 +312,14 @@ const topLvl = async (chainString, url, timestamp) => { }; const main = async (timestamp = null) => { - const data = await Promise.all( - Object.entries(chains).map(([chain, url]) => topLvl(chain, url, timestamp)) - ); + const data = await Promise.all([ + ...Object.entries(chains).map(([chain, url]) => + topLvl(chain, url, timestamp) + ), + ...Object.entries(dayDataChains).map(([chain, url]) => + topLvlDayData(chain, url) + ), + ]); return data .flat() From 6dd91c166d21a7a10c4732fa3456d19d5be53389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vuka=C5=A1in=20Manojlovi=C4=87?= Date: Mon, 13 Jul 2026 15:37:42 +0000 Subject: [PATCH 2/3] Use cursor instead of skip --- src/adaptors/uniswap-v4/index.js | 36 ++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/adaptors/uniswap-v4/index.js b/src/adaptors/uniswap-v4/index.js index 93ac91efaf..1552ca5ff2 100644 --- a/src/adaptors/uniswap-v4/index.js +++ b/src/adaptors/uniswap-v4/index.js @@ -175,9 +175,9 @@ const topLvl = async (chainString, url, timestamp) => { } }; -const latestPoolsQuery = (skip) => gql` +const latestPoolsQuery = (idCursor) => gql` { - pools(first: ${PAGE_SIZE}, skip: ${skip}, orderBy: id, where: {totalValueLockedUSD_gte: ${TVL_MIN}}) {${POOL_FIELDS} + pools(first: ${PAGE_SIZE}, orderBy: id, orderDirection: asc, where: {totalValueLockedUSD_gte: ${TVL_MIN}, id_gt: "${idCursor}"}) {${POOL_FIELDS} } } `; @@ -189,9 +189,10 @@ const poolsByIdQuery = (ids) => gql` } `; -const dayVolumesQuery = (dateGte, dateLte, skip) => gql` +const dayVolumesQuery = (dateGte, dateLte, idCursor) => gql` { - poolDayDatas(first: ${PAGE_SIZE}, skip: ${skip}, orderBy: id, where: {date_gte: ${dateGte}, date_lte: ${dateLte}}) { + poolDayDatas(first: ${PAGE_SIZE}, orderBy: id, orderDirection: asc, where: {date_gte: ${dateGte}, date_lte: ${dateLte}, id_gt: "${idCursor}"}) { + id date volumeUSD pool { @@ -202,12 +203,15 @@ const dayVolumesQuery = (dateGte, dateLte, skip) => gql` `; const fetchLatestPools = async (url) => { - let allPools = []; + const allPools = []; + let idCursor = ''; - for (let skip = 0; skip <= 5000; skip += PAGE_SIZE) { - const data = await request(url, latestPoolsQuery(skip)); - allPools = allPools.concat(data.pools ?? []); - if (!data.pools || data.pools.length < PAGE_SIZE) break; + while (true) { + const data = await request(url, latestPoolsQuery(idCursor)); + const page = data.pools ?? []; + allPools.push(...page); + if (page.length < PAGE_SIZE) break; + idCursor = page[page.length - 1].id; } return allPools; @@ -218,11 +222,16 @@ const fetchLatestPools = async (url) => { const fetchDayVolumes = async (url, previousDay) => { const volumesByPoolId = {}; const firstDay = previousDay - 6 * 86400; + let idCursor = ''; - for (let skip = 0; skip <= 5000; skip += PAGE_SIZE) { - const data = await request(url, dayVolumesQuery(firstDay, previousDay, skip)); + while (true) { + const data = await request( + url, + dayVolumesQuery(firstDay, previousDay, idCursor) + ); + const page = data.poolDayDatas ?? []; - for (const dayData of data.poolDayDatas ?? []) { + for (const dayData of page) { const poolId = dayData.pool.id; const volumes = volumesByPoolId[poolId] ?? { volumeUSD1d: 0, @@ -237,7 +246,8 @@ const fetchDayVolumes = async (url, previousDay) => { volumesByPoolId[poolId] = volumes; } - if (!data.poolDayDatas || data.poolDayDatas.length < PAGE_SIZE) break; + if (page.length < PAGE_SIZE) break; + idCursor = page[page.length - 1].id; } return volumesByPoolId; From 6c54e5c0ef4f3acb87384106a17138ea9a45e1fc Mon Sep 17 00:00:00 2001 From: kr3p <123127490+0xkr3p@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:00:14 +0100 Subject: [PATCH 3/3] reduce chunk to 50 & dedupe pools --- src/adaptors/uniswap-v3/onchain.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/adaptors/uniswap-v3/onchain.js b/src/adaptors/uniswap-v3/onchain.js index 92ee8e2c95..ed46263860 100644 --- a/src/adaptors/uniswap-v3/onchain.js +++ b/src/adaptors/uniswap-v3/onchain.js @@ -61,8 +61,8 @@ const getPricesChunked = async (tokens, chain) => { const pricesByAddress = {}; const pricesBySymbol = {}; - for (let start = 0; start < tokens.length; start += 100) { - const prices = await utils.getPrices(tokens.slice(start, start + 100), chain); + for (let start = 0; start < tokens.length; start += 50) { + const prices = await utils.getPrices(tokens.slice(start, start + 50), chain); Object.assign(pricesByAddress, prices.pricesByAddress); Object.assign(pricesBySymbol, prices.pricesBySymbol); } @@ -84,12 +84,15 @@ const getPools = async (chain) => { toBlock: currentBlock.number, }); - const pools = poolCreatedLogs.map((log) => ({ - token0: log.args.token0.toLowerCase(), - token1: log.args.token1.toLowerCase(), - address: log.args.pool.toLowerCase(), - fee: BigInt(log.args.fee), - })); + const seenPools = new Set(); + const pools = poolCreatedLogs + .map((log) => ({ + token0: log.args.token0.toLowerCase(), + token1: log.args.token1.toLowerCase(), + address: log.args.pool.toLowerCase(), + fee: BigInt(log.args.fee), + })) + .filter((p) => !seenPools.has(p.address) && seenPools.add(p.address)); const tokens = getUniqueAddresses( pools.map((p) => p.token0).concat(pools.map((p) => p.token1))