diff --git a/package.json b/package.json index bf2fc7b9..f88266ce 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "next start", "lint": "next lint", "generate-seo": "node scripts/generate-seo.mjs", + "test:unit": "node --test 'test/**/*.test.mjs'", "build-only": "next build", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", diff --git a/src/components/debug/hooks/useCreateProposal.js b/src/components/debug/hooks/useCreateProposal.js index 3a4dba8b..ca008923 100644 --- a/src/components/debug/hooks/useCreateProposal.js +++ b/src/components/debug/hooks/useCreateProposal.js @@ -3,13 +3,54 @@ import { ethers } from 'ethers'; import { useAccount, useChainId, useSwitchChain, useWalletClient, usePublicClient } from 'wagmi'; import { CHAIN_CONFIG, getExplorerTxUrl, getExplorerAddressUrl } from '../constants/chainConfig'; import { getEthersSigner } from '../../../utils/ethersAdapters'; +import { FUTARCHY_FACTORY_ABI, buildProposalParams } from '../../../features/marketCreation/proposalCalldata'; +import { findCreatedProposalAddress } from '../../../features/marketCreation/receiptEvents'; +import { getRpcUrls } from '../../../utils/getRpcUrl'; -// Futarchy Factory ABI -const FUTARCHY_FACTORY_ABI = [ - 'function createProposal((string,address,address,string,string,uint256,uint32)) returns (address)', - 'function proposals(uint256) view returns (address)', - 'function marketsCount() view returns (uint256)' -]; +// ethers v5 error codes that mean the RPC transport failed (try the next +// endpoint) rather than the call itself reverting (a real simulation result). +const TRANSPORT_ERROR_CODES = new Set(['NETWORK_ERROR', 'TIMEOUT', 'SERVER_ERROR']); + +/** + * Signerless createProposal simulation via callStatic, iterating the app's + * multi-RPC list. Shares ABI + param assembly with the broadcast path so the + * two provably encode identical calldata. + * @returns {{ok:boolean, message:string, proposalAddress?:string}} + */ +export async function simulateProposal(formData) { + const chainId = formData.chainId || 100; + const chainConfig = CHAIN_CONFIG[chainId]; + if (!chainConfig) return { ok: false, message: `Invalid chain: ${chainId}` }; + + let params; + try { + params = buildProposalParams(formData); + } catch (e) { + return { ok: false, message: e.message }; + } + + let lastTransportError = null; + for (const rpcUrl of getRpcUrls(chainId)) { + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + const factory = new ethers.Contract(chainConfig.factoryAddress, FUTARCHY_FACTORY_ABI, provider); + try { + const predicted = await factory.callStatic.createProposal(params, { + from: '0x000000000000000000000000000000000000dEaD', + }); + return { ok: true, message: `Would succeed — new proposal ${predicted}`, proposalAddress: predicted }; + } catch (e) { + if (TRANSPORT_ERROR_CODES.has(e.code)) { + lastTransportError = e; + continue; // RPC problem, not a verdict on the call — try the next endpoint + } + return { ok: false, message: `Would revert: ${e.reason || e.shortMessage || e.message}` }; + } + } + return { + ok: false, + message: `All RPC endpoints unreachable — try again (${lastTransportError?.message || 'no endpoints'})`, + }; +} /** * Hook for creating Futarchy proposals with multi-chain support @@ -62,7 +103,7 @@ export const useCreateProposal = () => { if (!formData.currencyToken || !/^0x[a-fA-F0-9]{40}$/.test(formData.currencyToken)) { return { isValid: false, error: 'Invalid currency token address' }; } - if (!formData.openingTime) { + if (!formData.openingTime && !Number.isFinite(formData.openingTimeUnix)) { return { isValid: false, error: 'Opening time is required' }; } return { isValid: true }; @@ -139,19 +180,8 @@ export const useCreateProposal = () => { freshSigner ); - // Convert opening time to unix timestamp - const openingTimeUnix = Math.floor(new Date(formData.openingTime).getTime() / 1000); - - // Prepare proposal parameters (tuple) - const params = [ - formData.marketName, - formData.companyToken, - formData.currencyToken, - formData.category, - formData.language, - formData.minBond, - openingTimeUnix - ]; + // Shared with simulateProposal — one calldata builder for both paths. + const params = buildProposalParams(formData); console.log('Creating proposal with params:', params); console.log('Factory address:', chainConfig.factoryAddress); @@ -170,43 +200,20 @@ export const useCreateProposal = () => { const receipt = await tx.wait(); console.log('Transaction confirmed:', receipt); - // Get new proposal address - let newProposalAddress = null; - - // Try to parse event logs first - try { - const iface = new ethers.utils.Interface([ - 'event ProposalCreated(address indexed proposal, string marketName)' - ]); - for (const log of receipt.logs) { - try { - const parsed = iface.parseLog({ topics: log.topics, data: log.data }); - if (parsed && parsed.name === 'ProposalCreated') { - newProposalAddress = parsed.args[0]; - break; - } - } catch { } - } - } catch { } - - // Fallback: get from marketsCount - if (!newProposalAddress) { - try { - const count = await factory.marketsCount(); - if (count.gt(0)) { - newProposalAddress = await factory.proposals(count.sub(1)); - } - } catch (e) { - console.error('Failed to get proposal address:', e); - } - } + // The factory's NewProposal event is the only deterministic result + // for this transaction. Never use the latest global proposal entry: + // another transaction may have landed before this receipt is read. + const newProposalAddress = findCreatedProposalAddress( + receipt.logs, + chainConfig.factoryAddress, + ); setProposalAddress(newProposalAddress); setStatus({ - type: 'success', + type: newProposalAddress ? 'success' : 'error', message: newProposalAddress ? `✅ Proposal created successfully!` - : '✅ Transaction confirmed! Check explorer for proposal address.' + : 'Transaction confirmed, but the factory NewProposal event was missing; cannot safely continue.' }); return { diff --git a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx index 4cca2998..efdeff75 100644 --- a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx +++ b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx @@ -1,11 +1,18 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; +import { ConnectButton } from '@rainbow-me/rainbowkit'; import { buildOneStepMarketPlan, - buildPermissionlessStackPlan, createMarketWizardDefaults, + deriveTwapTiming, KNOWN_ORGANIZATIONS, + GNOSIS_CHAIN_ID, + REALITY_OPENING_BUFFER_SECONDS, + stepOneBroadcastErrors, } from '../../../features/marketCreation/marketCreationWorkflow'; +import { validateMetadata } from '../../../features/marketCreation/validateMetadata'; +import { evaluateFloor, ZERO_TRADE_NOTICE, FLOOR_TRADE_USD, FLOOR_MAX_IMPACT } from '../../../features/marketCreation/liquidityFloor'; +import useCreateProposal, { simulateProposal } from '../../debug/hooks/useCreateProposal'; import RootLayout from '../../layout/RootLayout'; import PageLayout from '../../layout/PageLayout'; @@ -83,17 +90,215 @@ function MetadataPreview({ metadata }) { ); } +const badge = (ok) => ok + ? 'text-emerald-600 dark:text-emerald-400' + : 'text-amber-600 dark:text-amber-400'; + +// R1 floor gate + metadata validation + R3 honesty. Pre-creation, the floor is +// evaluated against the planned bootstrap seed (the honest lower bound): a +// pinhead seed correctly reads as DRAFT, which is the whole point — a wizard +// that mints dead markets would be worse than no wizard. +function ReadinessPanel({ metadataDraft, bootstrap }) { + // Pool data doesn't exist at step 1 — invert checks defer with a warning. + // nowUnix arms the window-starts-in-the-past gate against stale drafts. + const validation = useMemo( + () => validateMetadata(metadataDraft, { nowUnix: Math.floor(Date.now() / 1000) }), + [metadataDraft] + ); + // Treat the currency seed (~sDAI ≈ $1) as the input reserve; company seed as output. + const floor = useMemo(() => evaluateFloor({ + reserveInTokens: Number(bootstrap?.currencyToken || 0), + reserveOutTokens: Number(bootstrap?.companyToken || 0), + inputUsdPrice: 1, + }), [bootstrap]); + + return ( +
+

Readiness gate

+

+ A market goes live only when its metadata is valid and a ${FLOOR_TRADE_USD} trade moves + price under {(FLOOR_MAX_IMPACT * 100)}%. Below the floor it stays a draft. +

+ +
+
+ Liquidity floor + + {floor.state} + +
+

{floor.reason} (from the planned seed).

+
+ +
+
+ Metadata + + {validation.ok ? 'Valid' : `${validation.errors.length} issue${validation.errors.length === 1 ? '' : 's'}`} + +
+ {validation.errors.map((e, i) => ( +

• {e}

+ ))} + {validation.warnings.map((w, i) => ( +

• {w}

+ ))} +
+ +

{ZERO_TRADE_NOTICE}

+
+ ); +} + +// Real, wallet-connected proposal creation. Simulate-first (no broadcast) so the +// flow is demoable end-to-end without minting a market; Broadcast sends the tx. +function ExecutePanel({ form, organization }) { + const { isConnected, isSubmitting, status, transactionHash, proposalAddress, createProposal } = useCreateProposal(); + const [mode, setMode] = useState('simulate'); + const [simResult, setSimResult] = useState(null); + const [isSimulating, setIsSimulating] = useState(false); + + // Epoch seconds end-to-end: an ISO string re-parsed as local time shifted the + // on-chain openingTime by the operator's UTC offset. + const closeValid = Number.isFinite(form.closeTimestamp); + // marketName becomes the Reality.eth question text on chain and can never be + // changed, so it must be the full question, not just the proposal code. + const formData = closeValid ? { + chainId: GNOSIS_CHAIN_ID, + marketName: (form.question || '').trim(), + companyToken: organization.companyToken.address, + currencyToken: organization.currencyToken.address, + category: 'crypto', + language: 'en', + minBond: form.minBondWei, + openingTimeUnix: form.closeTimestamp + REALITY_OPENING_BUFFER_SECONDS, + } : null; + + // Re-evaluated on every render and again at click time so a page left open + // cannot broadcast a close date that has since become too soon. + const broadcastErrors = stepOneBroadcastErrors({ + question: form.question, + closeTimestamp: form.closeTimestamp, + twapDurationHours: form.twapDurationHours, + }); + // One proposal per wizard run: once a transaction was submitted, never offer + // a second broadcast (the factory would deploy a duplicate market). + const alreadySubmitted = Boolean(transactionHash || proposalAddress); + + const onRun = async () => { + if (!formData) return; + setSimResult(null); + if (mode === 'broadcast') { + if (alreadySubmitted) return; + const errors = stepOneBroadcastErrors({ + question: form.question, + closeTimestamp: form.closeTimestamp, + twapDurationHours: form.twapDurationHours, + }); + if (errors.length) { + setSimResult({ ok: false, msg: errors.join(' ') }); + return; + } + await createProposal(formData); + return; + } + setIsSimulating(true); + try { + const result = await simulateProposal(formData); + setSimResult({ ok: result.ok, msg: result.message }); + } finally { + setIsSimulating(false); + } + }; + + return ( +
+
+

Create proposal

+ +
+

+ The permissionless, proven first step. Simulate runs a static call against Gnosis with no + broadcast; Broadcast sends the real transaction from your wallet. Pools, liquidity manager, + Snapshot, and arbitrage follow as the staged plan below. +

+ +
+
+ {['simulate', 'broadcast'].map((m) => ( + + ))} +
+ + {!closeValid && ( + Pick a valid close date. + )} + {closeValid && mode === 'broadcast' && !isConnected && ( + Connect a wallet to broadcast. + )} + {mode === 'broadcast' && alreadySubmitted && ( + A proposal transaction was already sent from this page. Reload to start a new market. + )} +
+ {mode === 'broadcast' && !alreadySubmitted && broadcastErrors.length > 0 && ( +
+ {broadcastErrors.map((e) => ( +

• {e}

+ ))} +
+ )} +
+
+ + {simResult && ( +

+ {simResult.msg} +

+ )} + {status && ( +

{status.message}

+ )} + {transactionHash && ( +

tx: {transactionHash}

+ )} + {proposalAddress && ( +

proposal: {proposalAddress}

+ )} +
+ ); +} + export default function CreateMarketFlow() { const [organizationId, setOrganizationId] = useState('kleros'); - const defaults = useMemo( - () => createMarketWizardDefaults({ organizationId }), - [organizationId] - ); - const [form, setForm] = useState(defaults); + // Defaults are Date.now()-derived, and wallet panels use wagmi hooks — both + // must stay out of the static export. form stays null until client mount, so + // the exported HTML carries the page frame but no build-time timestamps + // (which caused React 18 hydration mismatches and days-stale dates). + const [form, setForm] = useState(null); + useEffect(() => { + setForm(createMarketWizardDefaults({ organizationId: 'kleros' })); + }, []); const selectedOrganization = KNOWN_ORGANIZATIONS[organizationId]; - const marketPlan = useMemo(() => buildOneStepMarketPlan({ ...form, organizationId }), [form, organizationId]); - const permissionlessPlan = useMemo(() => buildPermissionlessStackPlan(), []); + const marketPlan = useMemo( + () => (form ? buildOneStepMarketPlan({ ...form, organizationId }) : null), + [form, organizationId] + ); const updateOrganization = (nextOrganizationId) => { setOrganizationId(nextOrganizationId); @@ -106,13 +311,18 @@ export default function CreateMarketFlow() { const updateCloseDate = (value) => { const nextTimestamp = Math.floor(new Date(value).getTime() / 1000); - setForm((previous) => ({ - ...previous, - closeDateTimeLocal: value, - closeTimestamp: nextTimestamp, - twapStartTimestamp: nextTimestamp - (48 * 60 * 60), - startCandleUnix: nextTimestamp - (49 * 60 * 60), - })); + setForm((previous) => { + if (!value || !Number.isFinite(nextTimestamp)) { + // Cleared/invalid input: never store NaN — ExecutePanel disables on null. + return { ...previous, closeDateTimeLocal: value, closeTimestamp: null }; + } + return { + ...previous, + closeDateTimeLocal: value, + closeTimestamp: nextTimestamp, + ...deriveTwapTiming(nextTimestamp, previous.twapDurationHours), + }; + }); }; return ( @@ -135,20 +345,26 @@ export default function CreateMarketFlow() { -
-
-

Permissionless Chiado Stack

-

- This is the target testnet lifecycle: any wallet creates an organization, it is listed - automatically, and the organization receives a default liquidity manager for proposal liquidity. -

-
- -
-

Contract Actions

+ {!form ? ( + // Static-export frame: real wizard markup (headings, copy) but no + // timestamps — the interactive panels mount client-side. +
+ {['Create proposal', 'Readiness gate', 'Market Defaults', 'One-Step Execution Plan'].map((title) => ( +
+

{title}

+

Loading…

+
+ ))}
- -
+ ) : ( + <> +
+ + +
@@ -281,6 +497,8 @@ export default function CreateMarketFlow() {
+ + )} diff --git a/src/features/marketCreation/liquidityFloor.js b/src/features/marketCreation/liquidityFloor.js new file mode 100644 index 00000000..c87b8ab2 --- /dev/null +++ b/src/features/marketCreation/liquidityFloor.js @@ -0,0 +1,58 @@ +// R1 liquidity floor gate (ratified 2026-07-10): +// A market may only go LIVE if a $100 trade moves price < 3%. Below the floor it +// stays DRAFT. "A wizard that mints dead markets would be worse than no wizard." +// +// The pure core computes price impact from pool reserves via constant product, +// which is the honest lower-bound for a concentrated-liquidity pool near the +// active tick (real impact is >= this once liquidity thins). The async wrapper +// prefers the on-chain Algebra quoter (getAlgebraQuote) when available. + +export const FLOOR_TRADE_USD = 100; +export const FLOOR_MAX_IMPACT = 0.03; // 3% + +/** + * Post-trade SPOT price move of swapping amountIn into a constant-product pool. + * After the swap the marginal price (output per input) goes from ro/ri to + * ro'/ri' = (ro·ri)/(ri+ai)², so the move is 1 − (ri/(ri+ai))². This is what + * "a $100 trade moves price < 3%" means — the execution-price impact + * ai/(ri+ai) is roughly HALF of it and let markets go LIVE at half the + * intended depth. + * @returns {number} fractional spot-price move (0.03 = 3%) + */ +export function priceImpactConstantProduct(reserveIn, reserveOut, amountIn) { + const ri = Number(reserveIn), ro = Number(reserveOut), ai = Number(amountIn); + if (!(ri > 0 && ro > 0 && ai > 0)) return 1; // no/unknown liquidity => max impact => DRAFT + const remain = ri / (ri + ai); + return Math.max(0, 1 - remain * remain); +} + +/** + * Evaluate the floor gate for a pool. + * @param {object} p + * @param {number} p.reserveInTokens input-token reserve (currency side), human units + * @param {number} p.reserveOutTokens output-token reserve (company side), human units + * @param {number} p.tradeUsd trade size in USD (default $100) + * @param {number} p.inputUsdPrice USD price of one input token (currency ~ $1 for sDAI) + * @returns {{passes:boolean, impact:number, state:'LIVE'|'DRAFT', tradeUsd:number, reason:string}} + */ +export function evaluateFloor({ reserveInTokens, reserveOutTokens, tradeUsd = FLOOR_TRADE_USD, inputUsdPrice = 1 }) { + const amountIn = tradeUsd / (inputUsdPrice || 1); + const impact = priceImpactConstantProduct(reserveInTokens, reserveOutTokens, amountIn); + const passes = impact < FLOOR_MAX_IMPACT; + return { + passes, + impact, + state: passes ? 'LIVE' : 'DRAFT', + tradeUsd, + reason: passes + ? `a $${tradeUsd} trade moves price ${(impact * 100).toFixed(2)}% (< ${(FLOOR_MAX_IMPACT * 100)}% floor)` + : `a $${tradeUsd} trade moves price ${(impact * 100).toFixed(2)}% (>= ${(FLOOR_MAX_IMPACT * 100)}% floor) — seed more liquidity before going live`, + }; +} + +/** + * Zero-trade honesty copy (R3). Show this wherever resolution is previewed. + */ +export const ZERO_TRADE_NOTICE = + 'If the TWAP window ends with no trades, the market resolves by the default ' + + 'rule (no trading occurred) — this is not a market verdict.'; diff --git a/src/features/marketCreation/marketCreationWorkflow.js b/src/features/marketCreation/marketCreationWorkflow.js index d7001760..b56b20d7 100644 --- a/src/features/marketCreation/marketCreationWorkflow.js +++ b/src/features/marketCreation/marketCreationWorkflow.js @@ -386,6 +386,53 @@ export function getOrganizationDefaults(organizationId = 'gnosis') { return KNOWN_ORGANIZATIONS[organizationId] || KNOWN_ORGANIZATIONS.gnosis; } +// 0xAlex standard for high-stakes markets: TWAP live from proposal start, a +// 5-day (120h) window, ending 48h before vote close so the msig can act on the +// signal while voting is still open. The ONLY place these three fields are +// derived — the close-date edit path and the defaults must agree (the 24h-era +// copy of this formula in the UI shipped windows ending 72h after close). +export const TWAP_BUFFER_SECONDS = 48 * 60 * 60; + +// Reality.eth openingTime must fall after the Snapshot vote closes, with +// buffer, so answers only open once the result is known. Coincidentally also +// 48h — a distinct concept from the TWAP-end buffer above. +export const REALITY_OPENING_BUFFER_SECONDS = 48 * 60 * 60; + +export function deriveTwapTiming(closeTimestamp, twapDurationHours = 120) { + const twapStartTimestamp = closeTimestamp - TWAP_BUFFER_SECONDS - (twapDurationHours * 3600); + return { + twapDurationHours, + twapStartTimestamp, + startCandleUnix: twapStartTimestamp - 3600, + }; +} + +// Step 1 creates the proposal on chain and cannot be undone, so refuse to send +// it when the market could never go live: the Reality question text is the +// on-chain question forever, and a close date whose TWAP window has already +// started is one that step 2's metadata validator will reject. +export function stepOneBroadcastErrors({ + question, + closeTimestamp, + twapDurationHours = 120, + nowSeconds = Math.floor(Date.now() / 1000), +} = {}) { + const errors = []; + const text = typeof question === 'string' ? question.trim() : ''; + if (!text) { + errors.push('Write the resolution question.'); + } else { + if (!text.endsWith('?')) errors.push('The resolution question must end with "?".'); + if (/["\\␟]/.test(text)) errors.push('The resolution question cannot contain double quotes, backslashes or the ␟ character.'); + } + if (!Number.isFinite(closeTimestamp)) { + errors.push('Pick a valid close date.'); + } else if (deriveTwapTiming(closeTimestamp, twapDurationHours).twapStartTimestamp <= nowSeconds) { + errors.push('The close date is too soon: the TWAP window would already have started. Pick a later close date.'); + } + return errors; +} + export function createMarketWizardDefaults({ organizationId = 'gnosis', nowSeconds = Math.floor(Date.now() / 1000), @@ -393,7 +440,7 @@ export function createMarketWizardDefaults({ const organization = getOrganizationDefaults(organizationId); const proposalNumber = organization.id === 'kleros' ? '90' : '151'; const closeTimestamp = addDaysUnix(nowSeconds, 7); - const twapStartTimestamp = closeTimestamp - (48 * 60 * 60); + const { twapDurationHours, twapStartTimestamp, startCandleUnix } = deriveTwapTiming(closeTimestamp); return { mode: 'existing-org', @@ -412,9 +459,9 @@ export function createMarketWizardDefaults({ currencyToken: organization.currencyToken, closeTimestamp, closeDateTimeLocal: toDateTimeLocal(closeTimestamp), - startCandleUnix: twapStartTimestamp - (60 * 60), + startCandleUnix, twapStartTimestamp, - twapDurationHours: 24, + twapDurationHours, minBondWei: '1000000000000000000', eventProbability: 0.5, initialLiquidityMode: 'flm', diff --git a/src/features/marketCreation/proposalCalldata.js b/src/features/marketCreation/proposalCalldata.js new file mode 100644 index 00000000..ceb2d1b0 --- /dev/null +++ b/src/features/marketCreation/proposalCalldata.js @@ -0,0 +1,39 @@ +// The ONE place createProposal calldata is assembled. Both the broadcast path +// (useCreateProposal) and the simulate path import from here, so a signature or +// param change can never make "Would succeed" validate different calldata than +// the transaction that follows it. + +export const FUTARCHY_FACTORY_ABI = [ + 'function createProposal((string,address,address,string,string,uint256,uint32)) returns (address)', + 'function proposals(uint256) view returns (address)', + 'function marketsCount() view returns (uint256)', + 'event NewProposal(address indexed proposal, string marketName, bytes32 conditionId, bytes32 questionId)', +]; + +/** + * Build the createProposal param tuple from wizard/debug form data. + * openingTimeUnix (epoch seconds, number) is authoritative when present — + * the string `openingTime` path parses in the OPERATOR'S LOCAL timezone and + * exists only for the legacy debug modal's datetime-local input. + * @returns {Array} the 7-element tuple for createProposal + */ +export function buildProposalParams(formData) { + let openingUnix; + if (Number.isFinite(formData.openingTimeUnix)) { + openingUnix = Math.floor(formData.openingTimeUnix); + } else { + openingUnix = Math.floor(new Date(formData.openingTime).getTime() / 1000); + } + if (!Number.isFinite(openingUnix)) { + throw new Error('Invalid opening time'); + } + return [ + formData.marketName, + formData.companyToken, + formData.currencyToken, + formData.category, + formData.language, + formData.minBond, + openingUnix, + ]; +} diff --git a/src/features/marketCreation/receiptEvents.js b/src/features/marketCreation/receiptEvents.js new file mode 100644 index 00000000..62ec4206 --- /dev/null +++ b/src/features/marketCreation/receiptEvents.js @@ -0,0 +1,54 @@ +import { ethers } from 'ethers'; + +const FACTORY_EVENTS = [ + 'event NewProposal(address indexed proposal, string marketName, bytes32 conditionId, bytes32 questionId)', +]; + +const ORGANIZATION_EVENTS = [ + 'event ProposalCreatedAndAdded(address indexed proposalMetadata, address indexed proposalAddress)', +]; + +function sameAddress(left, right) { + return Boolean(left && right && left.toLowerCase() === right.toLowerCase()); +} + +function parseReceiptLog(log, iface, address) { + if (!log || !sameAddress(log.address, address)) return null; + try { + return iface.parseLog({ topics: log.topics, data: log.data }); + } catch { + return null; + } +} + +/** + * Return the proposal emitted by the factory transaction, or null when the + * receipt does not contain the factory's authoritative NewProposal event. + */ +export function findCreatedProposalAddress(logs, factoryAddress) { + const iface = new ethers.utils.Interface(FACTORY_EVENTS); + for (const log of logs || []) { + const parsed = parseReceiptLog(log, iface, factoryAddress); + if (parsed?.name === 'NewProposal') return parsed.args.proposal; + } + return null; +} + +/** + * Return the metadata contract emitted for this proposal, or null when the + * organization receipt does not contain a matching ProposalCreatedAndAdded. + * The proposal match prevents a concurrent org write from being accepted. + */ +export function findCreatedMetadataAddress(logs, organizationAddress, proposalAddress) { + const iface = new ethers.utils.Interface(ORGANIZATION_EVENTS); + for (const log of logs || []) { + const parsed = parseReceiptLog(log, iface, organizationAddress); + if ( + parsed?.name === 'ProposalCreatedAndAdded' + && sameAddress(parsed.args.proposalAddress, proposalAddress) + ) { + return parsed.args.proposalMetadata; + } + } + return null; +} diff --git a/src/features/marketCreation/validateMetadata.js b/src/features/marketCreation/validateMetadata.js new file mode 100644 index 00000000..bc8c8cfe --- /dev/null +++ b/src/features/marketCreation/validateMetadata.js @@ -0,0 +1,119 @@ +// Metadata validation for the Create Market wizard. +// +// Encodes the ratified guards against the incidents that actually shipped: +// - TWAP invert flags disagreeing with on-chain token0 order (KIP-88 & KIP-90: +// a ~22,000x price-display bug). The authoritative rule is the one the market +// page already uses (MarketPageShowcase.jsx fetchPoolTwap): +// invert === (token0 !== CONDITIONAL company token). The pools hold the +// wrapped YES/NO tokens, never the base company token — comparing against +// the base token makes the check always-wrong. +// - TWAP windows that start before the market exists → oracle reads revert. +// - Missing coingecko_ticker → spot price silently hidden. +// - JSON that doesn't survive a round-trip → frontend JSON.parse falls back to +// {} and the market renders wrong (the Gnosisscan escape-stripping incident). +// - updateExtendedMetadata overwrites the WHOLE blob, so every edit must be a +// read-modify-write that preserves all keys. + +const ADDR = /^0x[a-fA-F0-9]{40}$/; + +// Grace so a freshly-derived "live from proposal start" window doesn't error +// over seconds of review time. ponytail: step 2 (metadata write) must +// recompute the window at write time; this gate just refuses stale drafts. +const TWAP_START_GRACE_SECONDS = 15 * 60; + +/** + * Validate a metadata object before it is written on-chain. + * @param {object} metadata the metadata blob to write + * @param {object} [ctx] + * @param {{yes?:{token0:string,conditionalCompanyToken:string}, + * no?:{token0:string,conditionalCompanyToken:string}}} [ctx.pools] + * Per-pool on-chain token0() and the pool's wrapped (conditional) + * company token. Omit before pools exist — invert checks are deferred. + * @param {number} [ctx.nowUnix] current epoch seconds; enables the + * window-starts-in-the-past check. + * @returns {{ok:boolean, errors:string[], warnings:string[], corrected:object|null}} + */ +export function validateMetadata(metadata, ctx = {}) { + const errors = []; + const warnings = []; + let corrected = null; + const m = metadata || {}; + + // JSON round-trip: the object must serialize and parse back identically. + try { + const rt = JSON.parse(JSON.stringify(m)); + if (JSON.stringify(rt) !== JSON.stringify(m)) { + errors.push('metadata is not JSON round-trip stable (would corrupt on write)'); + } + } catch (e) { + errors.push(`metadata is not serializable: ${e.message}`); + } + + // Required keys the market page and TWAP pipeline depend on. + for (const key of ['chain', 'snapshot_id', 'closeTimestamp', 'twapStartTimestamp', 'twapDurationHours']) { + if (m[key] === undefined || m[key] === null || m[key] === '') { + errors.push(`missing required key: ${key}`); + } + } + + // Ticker present, else spot price is silently hidden. + if (!m.coingecko_ticker) { + errors.push('coingecko_ticker is empty — spot price will be hidden on the market page'); + } + + // TWAP window must not start before the market can exist (a page-load-stale + // draft would record a window predating the pools → oracle reads revert). + if (Number.isFinite(ctx.nowUnix) && Number.isFinite(Number(m.twapStartTimestamp)) + && Number(m.twapStartTimestamp) < ctx.nowUnix - TWAP_START_GRACE_SECONDS) { + errors.push('TWAP window starts in the past (before market creation) — move the close date later or shorten the window'); + } + + // Invert flags vs on-chain token order (the authoritative check). Each pool + // holds the CONDITIONAL company token; the base token never appears in a pool. + const pools = ctx.pools || {}; + const sides = ['yes', 'no'].filter((s) => pools[s]?.token0 && pools[s]?.conditionalCompanyToken); + if (sides.length) { + const fixes = {}; + for (const side of sides) { + const token0 = String(pools[side].token0).toLowerCase(); + const conditional = String(pools[side].conditionalCompanyToken).toLowerCase(); + if (!ADDR.test(token0) || !ADDR.test(conditional)) continue; + const expected = token0 !== conditional; // raw price = token1/token0; invert when the conditional company token isn't token0 + const flagKey = side === 'yes' ? 'invertTwapPoolYes' : 'invertTwapPoolNo'; + const actual = Boolean(m[flagKey]); + if (actual !== expected) { + errors.push(`${flagKey}=${actual} disagrees with on-chain token0 (should be ${expected}) — the shipped-wrong-price bug`); + fixes[flagKey] = expected; + } + } + if (Object.keys(fixes).length) corrected = { ...m, ...fixes }; + } else { + warnings.push('invert flags not verified — pools do not exist yet (re-run after pool creation)'); + } + + return { ok: errors.length === 0, errors, warnings, corrected }; +} + +/** + * Read-modify-write helper. Given the FULL existing on-chain metadata and a + * patch, return the merged blob so updateExtendedMetadata never drops keys. + * Throws if `existing` doesn't parse — refusing to write is safer than + * overwriting the whole blob with a partial object. + * @param {string|object} existing current on-chain metadata (raw string or object) + * @param {object} patch keys to change/add + * @returns {object} merged metadata, safe to serialize and write + */ +export function mergeMetadataForUpdate(existing, patch) { + let base; + if (typeof existing === 'string') { + try { + base = JSON.parse(existing || '{}'); + } catch (e) { + throw new Error(`refusing to write: existing metadata does not parse (${e.message}). ` + + 'Overwriting would drop every key.'); + } + } else { + base = { ...(existing || {}) }; + } + return { ...base, ...(patch || {}) }; +} diff --git a/src/pages/markets/new/index.jsx b/src/pages/markets/new/index.jsx index 02c80c80..de3c8253 100644 --- a/src/pages/markets/new/index.jsx +++ b/src/pages/markets/new/index.jsx @@ -1,16 +1,8 @@ -import dynamic from 'next/dynamic'; - -const CreateMarketFlow = dynamic( - () => import('../../../components/futarchyFi/createMarket/CreateMarketFlow'), - { - ssr: false, - loading: () => ( -
-
-
- ), - } -); +// Static import (not next/dynamic ssr:false): with output:'export' the dynamic +// chunk 404'd, leaving /markets/new an empty shell in production (PR #81). +// CreateMarketFlow renders its static planner during SSG and mounts the +// wallet-connected panels client-side behind a mounted guard. +import CreateMarketFlow from '../../../components/futarchyFi/createMarket/CreateMarketFlow'; export default function NewMarketPage() { return ; diff --git a/test/marketCreation.test.mjs b/test/marketCreation.test.mjs new file mode 100644 index 00000000..2807c4c8 --- /dev/null +++ b/test/marketCreation.test.mjs @@ -0,0 +1,153 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { validateMetadata, mergeMetadataForUpdate } from '../src/features/marketCreation/validateMetadata.js'; +import { priceImpactConstantProduct, evaluateFloor } from '../src/features/marketCreation/liquidityFloor.js'; +import { deriveTwapTiming, TWAP_BUFFER_SECONDS, stepOneBroadcastErrors } from '../src/features/marketCreation/marketCreationWorkflow.js'; +import { buildProposalParams } from '../src/features/marketCreation/proposalCalldata.js'; +import { findCreatedProposalAddress } from '../src/features/marketCreation/receiptEvents.js'; +import { ethers } from 'ethers'; + +// Conditional (wrapped YES/NO) company tokens — what the pools actually hold. +const COND_YES = '0x1111111111111111111111111111111111111111'; +const COND_NO = '0x2222222222222222222222222222222222222222'; +const OTHER = '0x0000000000000000000000000000000000000001'; +const GOOD = { + chain: 100, snapshot_id: '0xabc', closeTimestamp: 1790000000, + twapStartTimestamp: 1789000000, twapDurationHours: 120, + coingecko_ticker: 'pnk-ticker', + invertTwapPoolYes: false, invertTwapPoolNo: false, +}; +const POOLS_ALIGNED = { + yes: { token0: COND_YES, conditionalCompanyToken: COND_YES }, + no: { token0: COND_NO, conditionalCompanyToken: COND_NO }, +}; + +test('valid metadata passes', () => { + // conditional company token IS token0 in both pools => invert false, matches GOOD + const r = validateMetadata(GOOD, { pools: POOLS_ALIGNED }); + assert.equal(r.ok, true, r.errors.join('; ')); +}); + +test('wrong invert flag is caught with a correction (the shipped-wrong-price bug)', () => { + const bad = { ...GOOD, invertTwapPoolYes: true }; // conditional token is token0 => should be false + const r = validateMetadata(bad, { pools: POOLS_ALIGNED }); + assert.equal(r.ok, false); + assert.ok(r.errors.some(e => e.includes('invertTwapPoolYes'))); + assert.equal(r.corrected.invertTwapPoolYes, false); +}); + +test('invert flag must be true when the conditional company token is NOT token0', () => { + const r = validateMetadata(GOOD, { + pools: { + yes: { token0: OTHER, conditionalCompanyToken: COND_YES }, + no: { token0: OTHER, conditionalCompanyToken: COND_NO }, + }, + }); + assert.equal(r.ok, false); // GOOD has false, but conditional!=token0 => expected true + assert.equal(r.corrected.invertTwapPoolYes, true); +}); + +test('missing ticker is caught', () => { + const { coingecko_ticker, ...noTicker } = GOOD; + const r = validateMetadata(noTicker); + assert.ok(r.errors.some(e => e.includes('coingecko_ticker'))); +}); + +test('invert check deferred before pools exist', () => { + const r = validateMetadata(GOOD, {}); // no pools + assert.ok(r.warnings.some(w => w.includes('pools do not exist yet'))); + assert.ok(!r.errors.some(e => e.includes('invert'))); +}); + +test('TWAP window starting in the past is an error; fresh window passes', () => { + const now = GOOD.twapStartTimestamp + 3600; // draft went stale by an hour + const stale = validateMetadata(GOOD, { nowUnix: now }); + assert.ok(stale.errors.some(e => e.includes('starts in the past'))); + const fresh = validateMetadata(GOOD, { nowUnix: GOOD.twapStartTimestamp + 60 }); // within grace + assert.ok(!fresh.errors.some(e => e.includes('starts in the past'))); +}); + +test('deriveTwapTiming: window ends 48h before close, duration honored', () => { + const close = 1790000000; + const t = deriveTwapTiming(close, 120); + assert.equal(t.twapStartTimestamp + 120 * 3600, close - TWAP_BUFFER_SECONDS); // ends 48h before close + assert.equal(t.startCandleUnix, t.twapStartTimestamp - 3600); + assert.equal(t.twapDurationHours, 120); + const t24 = deriveTwapTiming(close, 24); + assert.equal(t24.twapStartTimestamp, close - TWAP_BUFFER_SECONDS - 24 * 3600); +}); + +test('read-modify-write preserves all keys', () => { + const existing = JSON.stringify({ a: 1, snapshot_id: '0x1', keepme: 'yes' }); + const merged = mergeMetadataForUpdate(existing, { snapshot_id: '0x2' }); + assert.equal(merged.a, 1); + assert.equal(merged.keepme, 'yes'); // not dropped + assert.equal(merged.snapshot_id, '0x2'); // patched +}); + +test('read-modify-write refuses to write over unparseable metadata', () => { + assert.throws(() => mergeMetadataForUpdate('{not json', { x: 1 }), /does not parse/); +}); + +test('floor: deep pool passes, thin pool is DRAFT', () => { + // ~$100k sDAI vs equivalent company => a $100 trade is tiny impact + const deep = evaluateFloor({ reserveInTokens: 100000, reserveOutTokens: 1000, inputUsdPrice: 1 }); + assert.equal(deep.passes, true); + assert.equal(deep.state, 'LIVE'); + // ~$300 pool => a $100 trade blows it out + const thin = evaluateFloor({ reserveInTokens: 300, reserveOutTokens: 3, inputUsdPrice: 1 }); + assert.equal(thin.passes, false); + assert.equal(thin.state, 'DRAFT'); +}); + +test('floor measures spot-price move, not execution impact (~2x smaller)', () => { + // $3,300 reserve: execution impact of a $100 trade is 100/3400 ≈ 2.9% (< 3%) + // but the spot move is 1 − (3300/3400)² ≈ 5.8% — must read DRAFT. + const impact = priceImpactConstantProduct(3300, 33, 100); + assert.ok(Math.abs(impact - (1 - (3300 / 3400) ** 2)) < 1e-12); + assert.ok(impact > 0.05 && impact < 0.06); + const r = evaluateFloor({ reserveInTokens: 3300, reserveOutTokens: 33, inputUsdPrice: 1 }); + assert.equal(r.state, 'DRAFT'); +}); + +test('floor: zero/unknown liquidity => max impact => DRAFT', () => { + assert.equal(priceImpactConstantProduct(0, 0, 100), 1); + const r = evaluateFloor({ reserveInTokens: 0, reserveOutTokens: 0 }); + assert.equal(r.state, 'DRAFT'); +}); + +test('buildProposalParams: epoch openingTimeUnix is authoritative, no Date round-trip', () => { + const base = { + marketName: 'KIP-90', companyToken: COND_YES, currencyToken: COND_NO, + category: 'crypto', language: 'en', minBond: '1', + }; + const p = buildProposalParams({ ...base, openingTimeUnix: 1790172800 }); + assert.equal(p[6], 1790172800); // exact epoch, immune to operator timezone + assert.throws(() => buildProposalParams({ ...base, openingTimeUnix: NaN, openingTime: '' }), /Invalid opening time/); +}); + +test('step 1 broadcast gate: full question and a close date whose TWAP has not started', () => { + const now = 1_800_000_000; + const close = now + 8 * 86400; + assert.deepEqual(stepOneBroadcastErrors({ question: 'Will KIP-90 be passed by Kleros?', closeTimestamp: close, nowSeconds: now }), []); + assert.equal(stepOneBroadcastErrors({ question: ' ', closeTimestamp: close, nowSeconds: now }).length, 1); + assert.match(stepOneBroadcastErrors({ question: 'KIP-90', closeTimestamp: close, nowSeconds: now })[0], /end with/); + assert.match(stepOneBroadcastErrors({ question: 'Will "x" pass?', closeTimestamp: close, nowSeconds: now })[0], /double quotes/); + assert.match(stepOneBroadcastErrors({ question: 'Will x pass?', closeTimestamp: NaN, nowSeconds: now })[0], /valid close date/); + // 120h TWAP + 48h buffer = 7 days before close; 6 days out has already started. + assert.match(stepOneBroadcastErrors({ question: 'Will x pass?', closeTimestamp: now + 6 * 86400, nowSeconds: now })[0], /too soon/); + assert.match(stepOneBroadcastErrors({ question: 'Will x pass?', closeTimestamp: now - 86400, nowSeconds: now })[0], /too soon/); +}); + +test('new proposal address comes only from the factory NewProposal event', () => { + const factory = '0x1000000000000000000000000000000000000001'; + const otherFactory = '0x1000000000000000000000000000000000000002'; + const proposal = '0x2000000000000000000000000000000000000001'; + const iface = new ethers.utils.Interface([ + 'event NewProposal(address indexed proposal, string marketName, bytes32 conditionId, bytes32 questionId)', + ]); + const log = iface.encodeEventLog(iface.getEvent('NewProposal'), [proposal, 'Will x pass?', ethers.constants.HashZero, ethers.constants.HashZero]); + assert.equal(findCreatedProposalAddress([{ address: otherFactory, ...log }], factory), null); + assert.equal(findCreatedProposalAddress([{ address: factory, ...log }], factory), proposal); + assert.equal(findCreatedProposalAddress([], factory), null); +});