Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions auto-qa/tests/active-market-liquidity.test.mjs
Original file line number Diff line number Diff line change
@@ -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' },
}), []);
});

6 changes: 5 additions & 1 deletion auto-qa/tests/market-creation-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']
);
});
9 changes: 3 additions & 6 deletions auto-qa/tests/subgraph-endpoints.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});

// ---------------------------------------------------------------------------
Expand Down
13 changes: 6 additions & 7 deletions auto-qa/tests/sushiswap-helper.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
9 changes: 9 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "/*"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/config/markets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down
9 changes: 7 additions & 2 deletions src/pages/market.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading
Loading