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.
+
+ 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.
+
+
+ {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');
+});