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.
+
+
+ );
+}
+
+// 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.
+
+
+ {!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.
+ )}
+
- 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.
+
@@ -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);
+});