From 997fefe6d4978df7926a530f1ef95033ec291802 Mon Sep 17 00:00:00 2001 From: "kas.eth" <5401138+krandder@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:55:33 +0000 Subject: [PATCH] Fix active liquidity filtering across chains --- .../tests/active-market-liquidity.test.mjs | 82 +++++++++++- src/utils/activeMarketLiquidity.js | 120 ++++++++++++------ 2 files changed, 158 insertions(+), 44 deletions(-) diff --git a/auto-qa/tests/active-market-liquidity.test.mjs b/auto-qa/tests/active-market-liquidity.test.mjs index 0154b8c8..08d6962c 100644 --- a/auto-qa/tests/active-market-liquidity.test.mjs +++ b/auto-qa/tests/active-market-liquidity.test.mjs @@ -13,7 +13,7 @@ const source = await readFile(sourcePath, 'utf8'); // import so Node can evaluate it in isolation under the repository's CJS mode. const testableSource = source.replace( "import { getSubgraphEndpoint } from '../config/subgraphEndpoints';", - 'const getSubgraphEndpoint = () => "https://example.invalid/graphql";' + 'const getSubgraphEndpoint = (chainId) => `https://chain-${chainId}.example/graphql`;' ); const liquidity = await import(`data:text/javascript;charset=utf-8,${encodeURIComponent(testableSource)}`); @@ -123,6 +123,85 @@ test('active filter requires both indexed pools and at least $1,000 combined rea assert.equal(exact[0].liquidityUsd, 1_000); }); +test('active filter queries and preserves Ethereum and Gnosis markets together', async () => { + const fixtures = { + 1: { + proposal: '0x0000000000000000000000000000000000000031', + yesPool: '0x0000000000000000000000000000000000000041', + noPool: '0x0000000000000000000000000000000000000042', + company: '0x0000000000000000000000000000000000000051', + currency: '0x0000000000000000000000000000000000000052', + }, + 100: { + proposal: '0x0000000000000000000000000000000000000061', + yesPool: '0x0000000000000000000000000000000000000071', + noPool: '0x0000000000000000000000000000000000000072', + company: '0x0000000000000000000000000000000000000081', + currency: '0x0000000000000000000000000000000000000082', + }, + }; + const events = Object.entries(fixtures).map(([chainId, fixture]) => ({ + chainId: Number(chainId), + proposalAddress: fixture.proposal, + poolAddresses: { yes: fixture.yesPool, no: fixture.noPool }, + })); + const graphqlChains = []; + const rpcChains = []; + + const fetchImpl = async (url, options) => { + const textUrl = String(url); + if (textUrl.includes('/graphql')) { + const chainId = Number(textUrl.match(/chain-(\d+)/)?.[1]); + const fixture = fixtures[chainId]; + const { variables } = JSON.parse(options.body); + graphqlChains.push(chainId); + assert.deepEqual(variables.poolIds.sort(), [ + `${chainId}-${fixture.noPool}`, + `${chainId}-${fixture.yesPool}`, + ].sort()); + assert.deepEqual(variables.proposalIds, [`${chainId}-${fixture.proposal}`]); + return { + ok: true, + json: async () => ({ + data: { + pools: [ + { id: fixture.yesPool, proposal: fixture.proposal, type: 'CONDITIONAL', outcomeSide: 'YES', token0: fixture.company, token1: fixture.currency, tick: 0 }, + { id: fixture.noPool, proposal: fixture.proposal, type: 'CONDITIONAL', outcomeSide: 'NO', token0: fixture.company, token1: fixture.currency, tick: 0 }, + ], + whitelistedtokens: [ + { address: fixture.company, decimals: 18, role: 'YES_COMPANY' }, + { address: fixture.currency, decimals: 18, role: 'YES_CURRENCY' }, + ], + }, + }), + }; + } + + const chainId = Number(textUrl.match(/rpc-(\d+)/)?.[1]); + rpcChains.push(chainId); + const requests = JSON.parse(options.body); + return { + ok: true, + json: async () => requests.map((request) => ({ + jsonrpc: '2.0', + id: request.id, + // Four calls per chain: 250 company + 250 currency in both pools. + result: `0x${wei(250).toString(16)}`, + })), + }; + }; + + const active = await filterEventsByMinimumLiquidity(events, { + fetchImpl, + rpcUrls: { 1: 'https://rpc-1.example', 100: 'https://rpc-100.example' }, + }); + + assert.deepEqual(graphqlChains.sort((a, b) => a - b), [1, 100]); + assert.deepEqual(rpcChains.sort((a, b) => a - b), [1, 100]); + assert.deepEqual(active.map((event) => event.chainId).sort((a, b) => a - b), [1, 100]); + assert.ok(active.every((event) => event.liquidityUsd === 1_000)); +}); + test('active filter fails closed when either pool or RPC evidence is unavailable', async () => { const event = { chainId: 100, @@ -149,4 +228,3 @@ test('active filter fails closed when either pool or RPC evidence is unavailable rpcUrls: { 100: 'https://rpc.example' }, }), []); }); - diff --git a/src/utils/activeMarketLiquidity.js b/src/utils/activeMarketLiquidity.js index 0e322b96..2b1df344 100644 --- a/src/utils/activeMarketLiquidity.js +++ b/src/utils/activeMarketLiquidity.js @@ -14,6 +14,8 @@ const normalizeAddress = (value) => { return text.includes('-') ? text.split('-').at(-1) : text; }; +const chainAddressKey = (chainId, value) => `${Number(chainId)}:${normalizeAddress(value)}`; + const toHumanNumber = (rawValue, decimals) => { let raw; try { @@ -44,16 +46,24 @@ const isCurrencyToken = (token) => { export function calculatePoolLiquidityUsd(pool, tokensByAddress, balancesByTokenAndPool) { if (!pool) return null; + const chainId = Number(pool.chainId); + const hasChainId = Number.isFinite(chainId); const token0Address = normalizeAddress(pool.token0); const token1Address = normalizeAddress(pool.token1); const poolAddress = normalizeAddress(pool.id); - const token0 = tokensByAddress.get(token0Address); - const token1 = tokensByAddress.get(token1Address); + const token0 = (hasChainId && tokensByAddress.get(chainAddressKey(chainId, token0Address))) + || tokensByAddress.get(token0Address); + const token1 = (hasChainId && tokensByAddress.get(chainAddressKey(chainId, token1Address))) + || tokensByAddress.get(token1Address); if (!token0 || !token1 || isCurrencyToken(token0) === isCurrencyToken(token1)) return null; - const raw0 = balancesByTokenAndPool.get(`${token0Address}:${poolAddress}`); - const raw1 = balancesByTokenAndPool.get(`${token1Address}:${poolAddress}`); + const raw0 = (hasChainId + ? balancesByTokenAndPool.get(`${chainId}:${token0Address}:${poolAddress}`) + : undefined) ?? balancesByTokenAndPool.get(`${token0Address}:${poolAddress}`); + const raw1 = (hasChainId + ? balancesByTokenAndPool.get(`${chainId}:${token1Address}:${poolAddress}`) + : undefined) ?? balancesByTokenAndPool.get(`${token1Address}:${poolAddress}`); const amount0 = toHumanNumber(raw0, token0.decimals); const amount1 = toHumanNumber(raw1, token1.decimals); const tick = Number(pool.tick); @@ -80,23 +90,25 @@ const balanceOfCalldata = (poolAddress) => ( ); async function fetchIndexedPools(events, fetchImpl) { - const poolIds = []; - const proposalIds = []; + const idsByChain = new Map(); for (const event of events) { const chainId = Number(event.chainId || event.metadata?.chain || 100); + if (!idsByChain.has(chainId)) { + idsByChain.set(chainId, { poolIds: [], proposalIds: [] }); + } + const ids = idsByChain.get(chainId); const proposalAddress = normalizeAddress(event.proposalAddress || event.eventId); - if (proposalAddress) proposalIds.push(`${chainId}-${proposalAddress}`); + if (proposalAddress) ids.proposalIds.push(`${chainId}-${proposalAddress}`); for (const address of [event.poolAddresses?.yes, event.poolAddresses?.no]) { - if (address) poolIds.push(`${chainId}-${normalizeAddress(address)}`); + if (address) ids.poolIds.push(`${chainId}-${normalizeAddress(address)}`); } } - if (poolIds.length === 0 || proposalIds.length === 0) { + if (idsByChain.size === 0) { return { pools: [], tokens: [] }; } - const endpoint = getSubgraphEndpoint(100); const query = ` query ActiveMarketLiquidity($poolIds: [String!]!, $proposalIds: [String!]!) { pools(where: { id_in: $poolIds }, first: 1000) { @@ -107,46 +119,66 @@ async function fetchIndexedPools(events, fetchImpl) { } } `; - const response = await fetchImpl(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query, - variables: { - poolIds: [...new Set(poolIds)], - proposalIds: [...new Set(proposalIds)], - }, - }), - }); - if (!response.ok) throw new Error(`Liquidity index returned HTTP ${response.status}`); - const result = await response.json(); - if (result.errors?.length) throw new Error(result.errors[0]?.message || 'Liquidity index query failed'); - return { pools: result.data?.pools || [], tokens: result.data?.whitelistedtokens || [] }; -} -async function fetchReserveBalances(pools, events, fetchImpl, rpcUrls) { - const chainByPool = new Map(); - for (const event of events) { - const chainId = Number(event.chainId || event.metadata?.chain || 100); - for (const address of [event.poolAddresses?.yes, event.poolAddresses?.no]) { - if (address) chainByPool.set(normalizeAddress(address), chainId); + const results = await Promise.all([...idsByChain.entries()].map(async ([chainId, ids]) => { + if (ids.poolIds.length === 0 || ids.proposalIds.length === 0) { + return { pools: [], tokens: [] }; } - } + const endpoint = getSubgraphEndpoint(chainId); + if (!endpoint) { + console.warn(`[Active Milestones] No liquidity index configured for chain ${chainId}`); + return { pools: [], tokens: [] }; + } + + try { + const response = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query, + variables: { + poolIds: [...new Set(ids.poolIds)], + proposalIds: [...new Set(ids.proposalIds)], + }, + }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + if (result.errors?.length) throw new Error(result.errors[0]?.message || 'GraphQL query failed'); + return { + pools: (result.data?.pools || []).map((pool) => ({ ...pool, chainId })), + tokens: (result.data?.whitelistedtokens || []).map((token) => ({ ...token, chainId })), + }; + } catch (error) { + // Fail closed only for the affected chain. A Gnosis index outage must not + // hide independently verified Ethereum markets, or vice versa. + console.warn(`[Active Milestones] Chain ${chainId} liquidity index failed: ${error.message}`); + return { pools: [], tokens: [] }; + } + })); + + return { + pools: results.flatMap((result) => result.pools), + tokens: results.flatMap((result) => result.tokens), + }; +} + +async function fetchReserveBalances(pools, fetchImpl, rpcUrls) { const callsByChain = new Map(); let nextId = 1; for (const pool of pools) { if (String(pool.type || '').toUpperCase() !== 'CONDITIONAL') continue; const poolAddress = normalizeAddress(pool.id); - const chainId = chainByPool.get(poolAddress); - if (!chainId) continue; + const chainId = Number(pool.chainId); + if (!Number.isFinite(chainId)) continue; if (!callsByChain.has(chainId)) callsByChain.set(chainId, []); for (const token of [pool.token0, pool.token1]) { const tokenAddress = normalizeAddress(token); callsByChain.get(chainId).push({ id: nextId++, - key: `${tokenAddress}:${poolAddress}`, + key: `${chainId}:${tokenAddress}:${poolAddress}`, request: { jsonrpc: '2.0', id: nextId - 1, @@ -200,13 +232,18 @@ export async function filterEventsByMinimumLiquidity( try { const { pools, tokens } = await fetchIndexedPools(events, fetchImpl); - const poolByAddress = new Map(pools.map((pool) => [normalizeAddress(pool.id), pool])); - const tokensByAddress = new Map(tokens.map((token) => [normalizeAddress(token.address), token])); - const balances = await fetchReserveBalances(pools, events, fetchImpl, rpcUrls); + const poolByAddress = new Map( + pools.map((pool) => [chainAddressKey(pool.chainId, pool.id), pool]) + ); + const tokensByAddress = new Map( + tokens.map((token) => [chainAddressKey(token.chainId, token.address), token]) + ); + const balances = await fetchReserveBalances(pools, fetchImpl, rpcUrls); return events.filter((event) => { - const yesPool = poolByAddress.get(normalizeAddress(event.poolAddresses?.yes)); - const noPool = poolByAddress.get(normalizeAddress(event.poolAddresses?.no)); + const chainId = Number(event.chainId || event.metadata?.chain || 100); + const yesPool = poolByAddress.get(chainAddressKey(chainId, event.poolAddresses?.yes)); + const noPool = poolByAddress.get(chainAddressKey(chainId, event.poolAddresses?.no)); const yesUsd = calculatePoolLiquidityUsd(yesPool, tokensByAddress, balances); const noUsd = calculatePoolLiquidityUsd(noPool, tokensByAddress, balances); @@ -219,4 +256,3 @@ export async function filterEventsByMinimumLiquidity( return []; } } -