diff --git a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx index 4cca299..fd09878 100644 --- a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx +++ b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx @@ -1,11 +1,15 @@ -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, KNOWN_ORGANIZATIONS, + GNOSIS_CHAIN_ID, } 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 from '../../debug/hooks/useCreateProposal'; import RootLayout from '../../layout/RootLayout'; import PageLayout from '../../layout/PageLayout'; @@ -83,7 +87,171 @@ 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, companyTokenAddress, bootstrap }) { + const validation = useMemo( + () => validateMetadata(metadataDraft, { companyTokenAddress }), + [metadataDraft, companyTokenAddress] + ); + // 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 formData = { + chainId: GNOSIS_CHAIN_ID, + marketName: form.proposalCode, + companyToken: organization.companyToken.address, + currencyToken: organization.currencyToken.address, + category: 'crypto', + language: 'en', + minBond: form.minBondWei, + openingTime: new Date((Number(form.closeTimestamp) + 48 * 3600) * 1000).toISOString().slice(0, 16), + }; + + const onRun = async () => { + setSimResult(null); + if (mode === 'broadcast') { + await createProposal(formData); + return; + } + // Simulate: static-call the factory, no broadcast. + try { + const { ethers } = await import('ethers'); + const { CHAIN_CONFIG } = await import('../../debug/constants/chainConfig'); + const cfg = CHAIN_CONFIG[GNOSIS_CHAIN_ID]; + const provider = new ethers.providers.JsonRpcProvider( + process.env.NEXT_PUBLIC_GNOSIS_RPC || 'https://rpc.gnosischain.com' + ); + const abi = ['function createProposal((string,address,address,string,string,uint256,uint32)) returns (address)']; + const factory = new ethers.Contract(cfg.factoryAddress, abi, provider); + const openingUnix = Math.floor(new Date(formData.openingTime).getTime() / 1000); + const params = [formData.marketName, formData.companyToken, formData.currencyToken, + formData.category, formData.language, formData.minBond, openingUnix]; + const predicted = await factory.callStatic.createProposal(params, { + from: '0x000000000000000000000000000000000000dEaD', + }); + setSimResult({ ok: true, msg: `Would succeed — new proposal ${predicted}` }); + } catch (e) { + setSimResult({ ok: false, msg: e.reason || e.shortMessage || e.message }); + } + }; + + 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) => ( + + ))} +
+ + {mode === 'broadcast' && !isConnected && ( + Connect a wallet to broadcast. + )} +
+ + {simResult && ( +

+ {simResult.msg} +

+ )} + {status && ( +

{status.message}

+ )} + {transactionHash && ( +

tx: {transactionHash}

+ )} + {proposalAddress && ( +

proposal: {proposalAddress}

+ )} +
+ ); +} + export default function CreateMarketFlow() { + // Wallet-connected panels use wagmi hooks that must not run during static + // export — render them only after client mount. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); const [organizationId, setOrganizationId] = useState('kleros'); const defaults = useMemo( () => createMarketWizardDefaults({ organizationId }), @@ -93,7 +261,6 @@ export default function CreateMarketFlow() { const selectedOrganization = KNOWN_ORGANIZATIONS[organizationId]; const marketPlan = useMemo(() => buildOneStepMarketPlan({ ...form, organizationId }), [form, organizationId]); - const permissionlessPlan = useMemo(() => buildPermissionlessStackPlan(), []); const updateOrganization = (nextOrganizationId) => { setOrganizationId(nextOrganizationId); @@ -135,20 +302,21 @@ 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

-
- -
+
+ {mounted ? ( + + ) : ( +
+

Create proposal

+

Loading wallet…

+
+ )} + +
diff --git a/src/features/marketCreation/liquidityFloor.js b/src/features/marketCreation/liquidityFloor.js new file mode 100644 index 0000000..423e8e3 --- /dev/null +++ b/src/features/marketCreation/liquidityFloor.js @@ -0,0 +1,56 @@ +// 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% + +/** + * Constant-product price impact of swapping amountIn of the input reserve. + * impact = 1 - (executionPrice / spotPrice), all in output-per-input terms. + * @returns {number} fractional price impact (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 spot = ro / ri; // output per input at rest + const out = (ro * ai) / (ri + ai); // x*y=k output for amountIn + const exec = out / ai; // realized output per input + return Math.max(0, 1 - exec / spot); +} + +/** + * 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 d700176..35aa011 100644 --- a/src/features/marketCreation/marketCreationWorkflow.js +++ b/src/features/marketCreation/marketCreationWorkflow.js @@ -393,7 +393,11 @@ 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); + // 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. + const twapDurationHours = 120; + const twapStartTimestamp = closeTimestamp - (48 * 60 * 60) - (twapDurationHours * 60 * 60); return { mode: 'existing-org', @@ -414,7 +418,7 @@ export function createMarketWizardDefaults({ closeDateTimeLocal: toDateTimeLocal(closeTimestamp), startCandleUnix: twapStartTimestamp - (60 * 60), twapStartTimestamp, - twapDurationHours: 24, + twapDurationHours, minBondWei: '1000000000000000000', eventProbability: 0.5, initialLiquidityMode: 'flm', diff --git a/src/features/marketCreation/validateMetadata.js b/src/features/marketCreation/validateMetadata.js new file mode 100644 index 0000000..5c727aa --- /dev/null +++ b/src/features/marketCreation/validateMetadata.js @@ -0,0 +1,99 @@ +// 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: invert === (token0 !== companyToken). See +// MarketPageShowcase.jsx fetchPoolTwap. +// - 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}$/; + +/** + * Validate a metadata object before it is written on-chain. + * @param {object} metadata the metadata blob to write + * @param {object} [ctx] + * @param {string} [ctx.companyTokenAddress] + * @param {{yes?:string,no?:string}} [ctx.poolToken0] on-chain token0() per pool, + * lowercased. Omit before pools exist — invert checks are then deferred. + * @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'); + } + + // Invert flags vs on-chain token order (the authoritative check). + const company = (ctx.companyTokenAddress || '').toLowerCase(); + const t0 = ctx.poolToken0 || {}; + if (company && ADDR.test(company) && (t0.yes || t0.no)) { + const fixes = {}; + for (const side of ['yes', 'no']) { + const token0 = (t0[side] || '').toLowerCase(); + if (!token0) continue; + const expected = token0 !== company; // raw price = token1/token0; invert when company 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 if (company && ADDR.test(company)) { + 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 02c80c8..de3c825 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 0000000..76012ac --- /dev/null +++ b/test/marketCreation.test.mjs @@ -0,0 +1,73 @@ +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'; + +const COMPANY = '0x37b60f4e9a31a64ccc0024dce7d0fd07eaa0f7b3'; // PNK +const GOOD = { + chain: 100, snapshot_id: '0xabc', closeTimestamp: 1790000000, + twapStartTimestamp: 1789000000, twapDurationHours: 120, + coingecko_ticker: 'pnk-ticker', + invertTwapPoolYes: false, invertTwapPoolNo: false, +}; + +test('valid metadata passes', () => { + // company IS token0 in both pools => invert should be false, matches GOOD + const r = validateMetadata(GOOD, { companyTokenAddress: COMPANY, poolToken0: { yes: COMPANY, no: COMPANY } }); + 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 }; // but company is token0 => should be false + const r = validateMetadata(bad, { companyTokenAddress: COMPANY, poolToken0: { yes: COMPANY, no: COMPANY } }); + 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 company is NOT token0', () => { + const other = '0x0000000000000000000000000000000000000001'; + const r = validateMetadata(GOOD, { companyTokenAddress: COMPANY, poolToken0: { yes: other, no: other } }); + assert.equal(r.ok, false); // GOOD has false, but company!=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, { companyTokenAddress: COMPANY }); // no poolToken0 + assert.ok(r.warnings.some(w => w.includes('pools do not exist yet'))); +}); + +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: 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'); +});