diff --git a/auto-qa/tests/active-market-liquidity.test.mjs b/auto-qa/tests/active-market-liquidity.test.mjs new file mode 100644 index 00000000..0154b8c8 --- /dev/null +++ b/auto-qa/tests/active-market-liquidity.test.mjs @@ -0,0 +1,152 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '../..'); +const sourcePath = resolve(root, 'src/utils/activeMarketLiquidity.js'); +const source = await readFile(sourcePath, 'utf8'); + +// The pure reserve valuation has no runtime dependency; strip the application +// 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 liquidity = await import(`data:text/javascript;charset=utf-8,${encodeURIComponent(testableSource)}`); + +const { + calculatePoolLiquidityUsd, + filterEventsByMinimumLiquidity, + MIN_ACTIVE_MARKET_LIQUIDITY_USD, +} = liquidity; + +const COMPANY = '0x0000000000000000000000000000000000000001'; +const CURRENCY = '0x0000000000000000000000000000000000000002'; +const YES_POOL = '0x0000000000000000000000000000000000000011'; +const NO_POOL = '0x0000000000000000000000000000000000000012'; +const PROPOSAL = '0x0000000000000000000000000000000000000021'; + +const tokens = new Map([ + [COMPANY, { address: COMPANY, decimals: 18, role: 'YES_COMPANY' }], + [CURRENCY, { address: CURRENCY, decimals: 18, role: 'YES_CURRENCY' }], +]); + +const wei = (amount) => BigInt(amount) * 10n ** 18n; + +test('liquidity floor is exactly $1,000', () => { + assert.equal(MIN_ACTIVE_MARKET_LIQUIDITY_USD, 1_000); +}); + +test('pool valuation uses real reserves and values either token orientation', () => { + const balances = new Map([ + [`${COMPANY}:${YES_POOL}`, wei(600)], + [`${CURRENCY}:${YES_POOL}`, wei(400)], + ]); + const companyFirst = calculatePoolLiquidityUsd( + { id: YES_POOL, token0: COMPANY, token1: CURRENCY, tick: 0 }, + tokens, + balances + ); + assert.equal(companyFirst, 1_000); + + const reversedBalances = new Map([ + [`${CURRENCY}:${YES_POOL}`, wei(400)], + [`${COMPANY}:${YES_POOL}`, wei(600)], + ]); + const currencyFirst = calculatePoolLiquidityUsd( + { id: YES_POOL, token0: CURRENCY, token1: COMPANY, tick: 0 }, + tokens, + reversedBalances + ); + assert.equal(currencyFirst, 1_000); +}); + +test('pool valuation fails closed for missing balances or ambiguous token roles', () => { + const pool = { id: YES_POOL, token0: COMPANY, token1: CURRENCY, tick: 0 }; + assert.equal(calculatePoolLiquidityUsd(pool, tokens, new Map()), null); + + const ambiguous = new Map([ + [COMPANY, { decimals: 18, role: 'YES_COMPANY' }], + [CURRENCY, { decimals: 18, role: 'NO_COMPANY' }], + ]); + assert.equal(calculatePoolLiquidityUsd(pool, ambiguous, new Map()), null); +}); + +test('active filter requires both indexed pools and at least $1,000 combined real reserves', async () => { + const events = [{ + chainId: 100, + proposalAddress: PROPOSAL, + eventId: PROPOSAL, + poolAddresses: { yes: YES_POOL, no: NO_POOL }, + }]; + + const graphqlData = { + data: { + pools: [ + { id: YES_POOL, proposal: PROPOSAL, type: 'CONDITIONAL', outcomeSide: 'YES', token0: COMPANY, token1: CURRENCY, tick: 0 }, + { id: NO_POOL, proposal: PROPOSAL, type: 'CONDITIONAL', outcomeSide: 'NO', token0: COMPANY, token1: CURRENCY, tick: 0 }, + ], + whitelistedtokens: [...tokens.values()], + }, + }; + + const makeFetch = (currencyPerPool) => async (url, options) => { + if (String(url).includes('graphql')) { + return { ok: true, json: async () => graphqlData }; + } + const requests = JSON.parse(options.body); + return { + ok: true, + json: async () => requests.map((request, index) => ({ + jsonrpc: '2.0', + id: request.id, + // Per pool: 100 company + configurable currency at tick zero. + result: `0x${(index % 2 === 0 ? wei(100) : wei(currencyPerPool)).toString(16)}`, + })), + }; + }; + + const below = await filterEventsByMinimumLiquidity(events.map((event) => ({ ...event })), { + fetchImpl: makeFetch(399), + rpcUrls: { 100: 'https://rpc.example' }, + }); + assert.equal(below.length, 0, '$998 must remain hidden'); + + const exact = await filterEventsByMinimumLiquidity(events.map((event) => ({ ...event })), { + fetchImpl: makeFetch(400), + rpcUrls: { 100: 'https://rpc.example' }, + }); + assert.equal(exact.length, 1, '$1,000 must be active'); + assert.equal(exact[0].liquidityUsd, 1_000); +}); + +test('active filter fails closed when either pool or RPC evidence is unavailable', async () => { + const event = { + chainId: 100, + proposalAddress: PROPOSAL, + poolAddresses: { yes: YES_POOL, no: NO_POOL }, + }; + const fetchImpl = async (url) => { + if (String(url).includes('graphql')) { + return { + ok: true, + json: async () => ({ + data: { + pools: [{ id: YES_POOL, proposal: PROPOSAL, type: 'CONDITIONAL', token0: COMPANY, token1: CURRENCY, tick: 0 }], + whitelistedtokens: [...tokens.values()], + }, + }), + }; + } + return { ok: false, status: 503, json: async () => ({}) }; + }; + + assert.deepEqual(await filterEventsByMinimumLiquidity([event], { + fetchImpl, + rpcUrls: { 100: 'https://rpc.example' }, + }), []); +}); + diff --git a/auto-qa/tests/market-creation-workflow.test.mjs b/auto-qa/tests/market-creation-workflow.test.mjs index dd218ec2..431882c3 100644 --- a/auto-qa/tests/market-creation-workflow.test.mjs +++ b/auto-qa/tests/market-creation-workflow.test.mjs @@ -140,7 +140,11 @@ test('validation rejects flows that would link Snapshot before liquidity', () => const invalidPlan = buildOneStepMarketPlan({ organizationId: 'gnosis', nowSeconds: NOW, + closeTimestamp: NOW + (7 * 24 * 60 * 60), snapshotLinkAfterLiquidity: false, }); - assert.deepEqual(validateOneStepMarketPlan(invalidPlan).errors, ['snapshotLinkAfterLiquidity']); + assert.deepEqual( + validateOneStepMarketPlan(invalidPlan, { nowSeconds: NOW }).errors, + ['snapshotLinkAfterLiquidity'] + ); }); diff --git a/auto-qa/tests/subgraph-endpoints.test.mjs b/auto-qa/tests/subgraph-endpoints.test.mjs index e02a5fb4..40e7f9cd 100644 --- a/auto-qa/tests/subgraph-endpoints.test.mjs +++ b/auto-qa/tests/subgraph-endpoints.test.mjs @@ -55,16 +55,13 @@ test('subgraphEndpoints — SUBGRAPH_ENDPOINTS has entries for chain 1 and chain }); test('subgraphEndpoints — both chain endpoints point to api.futarchy.fi (pinned current state)', () => { - // Today both chains route through the same proxied Checkpoint. If - // we ever differentiate (e.g. native Mainnet subgraph vs Gnosis - // Checkpoint), this test surfaces the change. + // Both chains route through the Checkpoint service, with chain 1 selected + // explicitly and chain 100 remaining the default endpoint. const m1 = SRC.match(/\b1\s*:\s*['"]([^'"]+)['"]/); const m100 = SRC.match(/\b100\s*:\s*['"]([^'"]+)['"]/); assert.ok(m1 && m100); - assert.equal(m1[1], 'https://api.futarchy.fi/candles/graphql'); + assert.equal(m1[1], 'https://api.futarchy.fi/candles/graphql?chainId=1'); assert.equal(m100[1], 'https://api.futarchy.fi/candles/graphql'); - assert.equal(m1[1], m100[1], - `chain 1 and chain 100 endpoints diverged — confirm intentional`); }); // --------------------------------------------------------------------------- diff --git a/auto-qa/tests/sushiswap-helper.test.mjs b/auto-qa/tests/sushiswap-helper.test.mjs index 8fadf794..10198687 100644 --- a/auto-qa/tests/sushiswap-helper.test.mjs +++ b/auto-qa/tests/sushiswap-helper.test.mjs @@ -245,16 +245,15 @@ test('source — throws "Router address not found in route data" when routeProce }); // --------------------------------------------------------------------------- -// checkAndApproveToken — MaxUint256 infinite approval +// checkAndApproveToken — exact approval // --------------------------------------------------------------------------- -test('source — checkAndApproveToken uses MaxUint256 (infinite approval)', () => { - // Pinned: limited approval would force re-approval per swap (UX - // friction + extra gas). MaxUint256 = "approve once, swap forever". - // Trade-off: token compromise = full balance at risk; pinned-as-is. +test('source — checkAndApproveToken uses the exact required approval amount', () => { + // Unlimited approval is opt-in only. The default swap path limits router + // authority to the amount required by this operation. assert.match(SRC, - /tokenContract\.approve\(\s*spenderAddress,\s*ethers\.constants\.MaxUint256/, - `approval amount drifted from MaxUint256 (infinite)`); + /tokenContract\.approve\(\s*spenderAddress,\s*approvalAmountFor\(amount\)/, + `approval amount drifted from approvalAmountFor(amount)`); }); test('source — allowance check uses .lt() (strictly less than amount)', () => { diff --git a/netlify.toml b/netlify.toml index 365d9325..6f8073d1 100644 --- a/netlify.toml +++ b/netlify.toml @@ -24,6 +24,15 @@ [context.branch-deploy] command = "npm install --legacy-peer-deps && npm run build" +# The first GIP-153 deployment had empty pools at a stale opening price and was +# archived. Keep old bookmarks from landing on a dead market after its static +# page is removed from src/config/markets.js. +[[redirects]] + from = "/markets/0x1d1f3b43f3c61b815041e9092b1ba7ca37c63262" + to = "/markets/0x4120de9931fd29c8a6effea4df57a7c8760c1677" + status = 301 + force = true + # Security headers [[headers]] for = "/*" diff --git a/src/components/futarchyFi/companyList/page/EventsHighlightDataTransformer.jsx b/src/components/futarchyFi/companyList/page/EventsHighlightDataTransformer.jsx index 07fdd79c..97bfea95 100644 --- a/src/components/futarchyFi/companyList/page/EventsHighlightDataTransformer.jsx +++ b/src/components/futarchyFi/companyList/page/EventsHighlightDataTransformer.jsx @@ -2,6 +2,10 @@ import { collectAndFetchPoolPrices, attachPrefetchedPrices } from "../../../../u import { fetchProposalsFromAggregator } from "../../../../hooks/useAggregatorProposals"; import { DEFAULT_AGGREGATOR } from "../../../../config/subgraphEndpoints"; import { isClosedProposal, isResolvedProposal } from "../../../../utils/proposalLifecycle"; +import { + filterEventsByMinimumLiquidity, + MIN_ACTIVE_MARKET_LIQUIDITY_USD, +} from "../../../../utils/activeMarketLiquidity"; // Main function to fetch active milestones from the registry subgraph aggregator. // Returns proposals filtered by visibility (hidden visible only to owner/editor) @@ -50,10 +54,18 @@ export const fetchEventHighlightData = async (_companyId = "all", options = {}) // Filter out resolved and ended proposals. Ended proposals may still have // stale/missing resolution metadata, but they should not disappear from the // homepage; Recently Closed owns that state. - const activeSubgraphEvents = subgraphEvents.filter(p => + const lifecycleActiveEvents = subgraphEvents.filter(p => !isResolvedProposal(p) && !isClosedProposal(p, nowSeconds) ); + // "Active" means economically usable, not merely unresolved. Read real + // ERC-20 reserves from both conditional pools and fail closed when the + // indexer/RPC cannot prove at least the configured stable-dollar floor. + const activeSubgraphEvents = await filterEventsByMinimumLiquidity(lifecycleActiveEvents); + console.log( + `[Active Milestones] ${activeSubgraphEvents.length}/${lifecycleActiveEvents.length} markets meet the $${MIN_ACTIVE_MARKET_LIQUIDITY_USD} real-liquidity floor` + ); + // Bulk fetch prices for the remaining events if (activeSubgraphEvents.length > 0) { const priceMap = await collectAndFetchPoolPrices(activeSubgraphEvents); diff --git a/src/config/markets.js b/src/config/markets.js index 7f46fa42..5a11998f 100644 --- a/src/config/markets.js +++ b/src/config/markets.js @@ -1414,11 +1414,11 @@ export const MARKETS_CONFIG = { "closeTimestamp": 1792800000 } }, - "0x1d1f3b43f3c61b815041e9092b1ba7ca37c63262": { + "0x4120de9931fd29c8a6effea4df57a7c8760c1677": { "title": "What will the impact on GNO price be if GIP-153 is passed?", "description": "GIP-153: Should Gnosis Chain transition into the Ethereum Economic Zone?", "image": "https://www.cryptoninjas.net/wp-content/uploads/gnosis-crypto-ninjas.jpg", - "path": "/markets/0x1d1f3b43f3c61b815041e9092b1ba7ca37c63262", + "path": "/markets/0x4120de9931fd29c8a6effea4df57a7c8760c1677", "openGraph": { "title": "What will the impact on GNO price be if GIP-153 is passed? | Futarchy.fi", "description": "GIP-153: Should Gnosis Chain transition into the Ethereum Economic Zone?", diff --git a/src/pages/market.js b/src/pages/market.js index 4190a67f..0e63ada9 100644 --- a/src/pages/market.js +++ b/src/pages/market.js @@ -9,6 +9,10 @@ const CONFIGURED_MARKETS = new Set( getStaticMarketAddresses().map((address) => (address || '').toLowerCase()) ); +const SUPERSEDED_MARKETS = { + '0x1d1f3b43f3c61b815041e9092b1ba7ca37c63262': '0x4120de9931fd29c8a6effea4df57a7c8760c1677', +}; + const normalizeQueryValue = (value) => { if (Array.isArray(value)) return value[0]; return value; @@ -68,6 +72,7 @@ const MarketPage = () => { const hasQueryParams = Object.keys(router.query || {}).length > 0; const proposalId = proposalIdFromQuery ? String(proposalIdFromQuery).trim() : ''; + const canonicalProposalId = SUPERSEDED_MARKETS[proposalId.toLowerCase()] || proposalId; if (!hasQueryParams) { // Redirect to market page with default proposal ID @@ -77,11 +82,11 @@ const MarketPage = () => { // Normalize legacy query-based links to canonical /markets/:address // when the market exists in the generated static market configuration. - if (proposalId && isConfiguredMarket(proposalId)) { + if (canonicalProposalId && isConfiguredMarket(canonicalProposalId)) { const normalizedQuery = stripQueryAliases(router.query, ['proposalId', 'marketId', 'address', 'proposal', 'market']); router.replace( { - pathname: `/markets/${proposalId}`, + pathname: `/markets/${canonicalProposalId}`, query: normalizedQuery }, undefined, diff --git a/src/utils/activeMarketLiquidity.js b/src/utils/activeMarketLiquidity.js new file mode 100644 index 00000000..0e322b96 --- /dev/null +++ b/src/utils/activeMarketLiquidity.js @@ -0,0 +1,222 @@ +import { getSubgraphEndpoint } from '../config/subgraphEndpoints'; + +export const MIN_ACTIVE_MARKET_LIQUIDITY_USD = 1_000; + +const DEFAULT_RPC_URLS = { + 1: process.env.NEXT_PUBLIC_MAINNET_RPC_URL || 'https://ethereum-rpc.publicnode.com', + 100: process.env.NEXT_PUBLIC_RPC_URL || 'https://rpc.gnosischain.com', +}; + +const BALANCE_OF_SELECTOR = '70a08231'; + +const normalizeAddress = (value) => { + const text = String(value || '').toLowerCase(); + return text.includes('-') ? text.split('-').at(-1) : text; +}; + +const toHumanNumber = (rawValue, decimals) => { + let raw; + try { + raw = typeof rawValue === 'bigint' ? rawValue : BigInt(rawValue); + } catch (_) { + return null; + } + + const safeDecimals = Number(decimals); + if (!Number.isInteger(safeDecimals) || safeDecimals < 0 || safeDecimals > 36) return null; + + const divisor = 10n ** BigInt(safeDecimals); + const whole = raw / divisor; + const fractional = (raw % divisor).toString().padStart(safeDecimals, '0').slice(0, 12); + const value = Number(whole) + (fractional ? Number(`0.${fractional}`) : 0); + return Number.isFinite(value) ? value : null; +}; + +const isCurrencyToken = (token) => { + const role = String(token?.role || '').toUpperCase(); + return role.includes('CURRENCY') || role.includes('COLLATERAL'); +}; + +/** + * Value a conditional pool from its real ERC-20 reserves, not virtual V3/Algebra L. + * Currency wrappers (sDAI/USDS/etc.) are treated as the one-dollar numeraire. + */ +export function calculatePoolLiquidityUsd(pool, tokensByAddress, balancesByTokenAndPool) { + if (!pool) return null; + + 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); + + if (!token0 || !token1 || isCurrencyToken(token0) === isCurrencyToken(token1)) return null; + + const raw0 = balancesByTokenAndPool.get(`${token0Address}:${poolAddress}`); + const raw1 = balancesByTokenAndPool.get(`${token1Address}:${poolAddress}`); + const amount0 = toHumanNumber(raw0, token0.decimals); + const amount1 = toHumanNumber(raw1, token1.decimals); + const tick = Number(pool.tick); + + if (amount0 === null || amount1 === null || !Number.isFinite(tick)) return null; + + // Algebra tick is raw token1/token0. Correct for token decimal differences + // before using it to value the company-token reserve in currency units. + const token1PerToken0 = Math.exp(Math.log(1.0001) * tick) + * Math.pow(10, Number(token0.decimals) - Number(token1.decimals)); + if (!Number.isFinite(token1PerToken0) || token1PerToken0 <= 0) return null; + + const currencyIsToken0 = isCurrencyToken(token0); + const currencyAmount = currencyIsToken0 ? amount0 : amount1; + const companyAmount = currencyIsToken0 ? amount1 : amount0; + const currencyPerCompany = currencyIsToken0 ? 1 / token1PerToken0 : token1PerToken0; + const total = currencyAmount + companyAmount * currencyPerCompany; + + return Number.isFinite(total) && total >= 0 ? total : null; +} + +const balanceOfCalldata = (poolAddress) => ( + `0x${BALANCE_OF_SELECTOR}${normalizeAddress(poolAddress).replace(/^0x/, '').padStart(64, '0')}` +); + +async function fetchIndexedPools(events, fetchImpl) { + const poolIds = []; + const proposalIds = []; + + for (const event of events) { + const chainId = Number(event.chainId || event.metadata?.chain || 100); + const proposalAddress = normalizeAddress(event.proposalAddress || event.eventId); + if (proposalAddress) proposalIds.push(`${chainId}-${proposalAddress}`); + for (const address of [event.poolAddresses?.yes, event.poolAddresses?.no]) { + if (address) poolIds.push(`${chainId}-${normalizeAddress(address)}`); + } + } + + if (poolIds.length === 0 || proposalIds.length === 0) { + return { pools: [], tokens: [] }; + } + + const endpoint = getSubgraphEndpoint(100); + const query = ` + query ActiveMarketLiquidity($poolIds: [String!]!, $proposalIds: [String!]!) { + pools(where: { id_in: $poolIds }, first: 1000) { + id proposal type outcomeSide token0 token1 tick + } + whitelistedtokens(where: { proposal_in: $proposalIds }, first: 2000) { + proposal address decimals role symbol + } + } + `; + 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 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; + 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}`, + request: { + jsonrpc: '2.0', + id: nextId - 1, + method: 'eth_call', + params: [{ to: tokenAddress, data: balanceOfCalldata(poolAddress) }, 'latest'], + }, + }); + } + } + + const balances = new Map(); + await Promise.all([...callsByChain.entries()].map(async ([chainId, calls]) => { + const rpcUrl = rpcUrls[chainId]; + if (!rpcUrl) return; + const response = await fetchImpl(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(calls.map(({ request }) => request)), + }); + if (!response.ok) return; + const results = await response.json(); + if (!Array.isArray(results)) return; + const resultById = new Map(results.map((item) => [item.id, item])); + for (const call of calls) { + const result = resultById.get(call.id); + if (!result?.result || result.error) continue; + try { + balances.set(call.key, BigInt(result.result)); + } catch (_) { + // Fail closed for this market below. + } + } + })); + + return balances; +} + +/** + * Fail closed: a market appears in Active Milestones only when both + * conditional pools are indexed and their real reserves prove the floor. + */ +export async function filterEventsByMinimumLiquidity( + events, + { + minimumUsd = MIN_ACTIVE_MARKET_LIQUIDITY_USD, + fetchImpl = fetch, + rpcUrls = DEFAULT_RPC_URLS, + } = {} +) { + if (!events?.length) return []; + + 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); + + return events.filter((event) => { + const yesPool = poolByAddress.get(normalizeAddress(event.poolAddresses?.yes)); + const noPool = poolByAddress.get(normalizeAddress(event.poolAddresses?.no)); + const yesUsd = calculatePoolLiquidityUsd(yesPool, tokensByAddress, balances); + const noUsd = calculatePoolLiquidityUsd(noPool, tokensByAddress, balances); + + if (yesUsd === null || noUsd === null) return false; + event.liquidityUsd = yesUsd + noUsd; + return event.liquidityUsd >= minimumUsd; + }); + } catch (error) { + console.warn('[Active Milestones] Liquidity gate failed closed:', error.message); + return []; + } +} +