From 70ef2c173b2d0878ab1d90b0c05b353f632c04c0 Mon Sep 17 00:00:00 2001 From: krandder Date: Sat, 26 Sep 2026 21:03:29 +0000 Subject: [PATCH 1/4] Create Market wizard: real transactor + R1 floor gate + metadata validator (hub-e19) - Wallet-connected createProposal (simulate-first) via existing useCreateProposal - R1 liquidity floor gate ($100 trade <3% else DRAFT); R3 zero-trade honesty - validateMetadata: invert-flag vs token0, ticker, JSON round-trip, read-modify-write - 0xAlex TWAP defaults (live-from-start, 120h window, 48h msig buffer) - Static-export fix (was PR #81): /markets/new rendered empty shell in prod - 9/9 node:test units --- .../createMarket/CreateMarketFlow.jsx | 202 ++++++++++++++++-- src/features/marketCreation/liquidityFloor.js | 56 +++++ .../marketCreation/marketCreationWorkflow.js | 8 +- .../marketCreation/validateMetadata.js | 99 +++++++++ src/pages/markets/new/index.jsx | 18 +- test/marketCreation.test.mjs | 73 +++++++ 6 files changed, 424 insertions(+), 32 deletions(-) create mode 100644 src/features/marketCreation/liquidityFloor.js create mode 100644 src/features/marketCreation/validateMetadata.js create mode 100644 test/marketCreation.test.mjs diff --git a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx index 4cca2998..fd09878c 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 00000000..423e8e32 --- /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 d7001760..35aa0115 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 00000000..5c727aae --- /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 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..76012aca --- /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'); +}); From f1884adb9bffc382ae21f633d15785ac552469dc Mon Sep 17 00:00:00 2001 From: krandder Date: Thu, 6 Aug 2026 22:56:13 -0700 Subject: [PATCH 2/4] Fix the 10 review findings on the wizard (hub-e19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timing correctness: - deriveTwapTiming() is the single source of the TWAP window; the close-date edit handler shared a stale 24h-era formula that published windows ending 72h AFTER vote close. - openingTime is epoch seconds end-to-end (openingTimeUnix); the ISO string round-trip shifted the on-chain value by the operator's UTC offset. REALITY_OPENING_BUFFER_SECONDS names the close→opening gap. - validateMetadata errors when the TWAP window starts >15min in the past (page-load-stale drafts predated pool creation → oracle reads revert). Gates: - Liquidity floor now measures the post-trade spot move 1-(ri/(ri+ai))^2, not execution impact (~2x smaller) — no more LIVE at half the depth. - Invert-flag validation compares pool token0 against the CONDITIONAL company token per pool (the market page's authoritative rule); checks defer with a warning until pools exist. Comparing the base token made the check always-wrong once wired — the KIP-88/90 bug reborn. Robustness: - Shared proposalCalldata.js builds the createProposal tuple for both simulate and broadcast — identical calldata by construction. - simulateProposal() iterates getRpcUrls(100); transport failures try the next endpoint, reverts are reported as verdicts. - Clearing the close date stores null and disables actions (no NaN white-screen); form defaults are client-mount-only so the static export bakes no build-time timestamps (no hydration mismatch). Tests: npm run test:unit wired, 13/13 pass. next build green; exported /markets/new keeps wizard markup with zero embedded timestamps. --- package.json | 1 + .../debug/hooks/useCreateProposal.js | 69 +++++++--- .../createMarket/CreateMarketFlow.jsx | 121 ++++++++++-------- src/features/marketCreation/liquidityFloor.js | 16 ++- .../marketCreation/marketCreationWorkflow.js | 29 ++++- .../marketCreation/proposalCalldata.js | 38 ++++++ .../marketCreation/validateMetadata.js | 48 +++++-- test/marketCreation.test.mjs | 72 +++++++++-- 8 files changed, 283 insertions(+), 111 deletions(-) create mode 100644 src/features/marketCreation/proposalCalldata.js 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..1333cfa4 100644 --- a/src/components/debug/hooks/useCreateProposal.js +++ b/src/components/debug/hooks/useCreateProposal.js @@ -3,13 +3,53 @@ 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 { 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 +102,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 +179,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); diff --git a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx index fd09878c..2bb588ae 100644 --- a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx +++ b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx @@ -4,12 +4,14 @@ import { ConnectButton } from '@rainbow-me/rainbowkit'; import { buildOneStepMarketPlan, createMarketWizardDefaults, + deriveTwapTiming, KNOWN_ORGANIZATIONS, GNOSIS_CHAIN_ID, + REALITY_OPENING_BUFFER_SECONDS, } 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 useCreateProposal, { simulateProposal } from '../../debug/hooks/useCreateProposal'; import RootLayout from '../../layout/RootLayout'; import PageLayout from '../../layout/PageLayout'; @@ -95,10 +97,12 @@ const badge = (ok) => ok // 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 }) { +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, { companyTokenAddress }), - [metadataDraft, companyTokenAddress] + () => 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({ @@ -151,8 +155,12 @@ 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); - const formData = { + // 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); + const formData = closeValid ? { chainId: GNOSIS_CHAIN_ID, marketName: form.proposalCode, companyToken: organization.companyToken.address, @@ -160,34 +168,22 @@ function ExecutePanel({ form, organization }) { category: 'crypto', language: 'en', minBond: form.minBondWei, - openingTime: new Date((Number(form.closeTimestamp) + 48 * 3600) * 1000).toISOString().slice(0, 16), - }; + openingTimeUnix: form.closeTimestamp + REALITY_OPENING_BUFFER_SECONDS, + } : null; const onRun = async () => { + if (!formData) return; setSimResult(null); if (mode === 'broadcast') { await createProposal(formData); return; } - // Simulate: static-call the factory, no broadcast. + setIsSimulating(true); 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 }); + const result = await simulateProposal(formData); + setSimResult({ ok: result.ok, msg: result.message }); + } finally { + setIsSimulating(false); } }; @@ -219,12 +215,15 @@ function ExecutePanel({ form, organization }) {
- {mode === 'broadcast' && !isConnected && ( + {!closeValid && ( + Pick a valid close date. + )} + {closeValid && mode === 'broadcast' && !isConnected && ( Connect a wallet to broadcast. )}
@@ -248,19 +247,21 @@ function ExecutePanel({ form, organization }) { } 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 }), - [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 marketPlan = useMemo( + () => (form ? buildOneStepMarketPlan({ ...form, organizationId }) : null), + [form, organizationId] + ); const updateOrganization = (nextOrganizationId) => { setOrganizationId(nextOrganizationId); @@ -273,13 +274,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 ( @@ -302,18 +308,23 @@ export default function CreateMarketFlow() { + {!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…

+
+ ))} +
+ ) : ( + <>
- {mounted ? ( - - ) : ( -
-

Create proposal

-

Loading wallet…

-
- )} +
@@ -449,6 +460,8 @@ export default function CreateMarketFlow() { + + )} diff --git a/src/features/marketCreation/liquidityFloor.js b/src/features/marketCreation/liquidityFloor.js index 423e8e32..c87b8ab2 100644 --- a/src/features/marketCreation/liquidityFloor.js +++ b/src/features/marketCreation/liquidityFloor.js @@ -11,17 +11,19 @@ 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%) + * 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 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); + const remain = ri / (ri + ai); + return Math.max(0, 1 - remain * remain); } /** diff --git a/src/features/marketCreation/marketCreationWorkflow.js b/src/features/marketCreation/marketCreationWorkflow.js index 35aa0115..c8575432 100644 --- a/src/features/marketCreation/marketCreationWorkflow.js +++ b/src/features/marketCreation/marketCreationWorkflow.js @@ -386,6 +386,27 @@ 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, + }; +} + export function createMarketWizardDefaults({ organizationId = 'gnosis', nowSeconds = Math.floor(Date.now() / 1000), @@ -393,11 +414,7 @@ export function createMarketWizardDefaults({ const organization = getOrganizationDefaults(organizationId); const proposalNumber = organization.id === 'kleros' ? '90' : '151'; const closeTimestamp = addDaysUnix(nowSeconds, 7); - // 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); + const { twapDurationHours, twapStartTimestamp, startCandleUnix } = deriveTwapTiming(closeTimestamp); return { mode: 'existing-org', @@ -416,7 +433,7 @@ export function createMarketWizardDefaults({ currencyToken: organization.currencyToken, closeTimestamp, closeDateTimeLocal: toDateTimeLocal(closeTimestamp), - startCandleUnix: twapStartTimestamp - (60 * 60), + startCandleUnix, twapStartTimestamp, twapDurationHours, minBondWei: '1000000000000000000', diff --git a/src/features/marketCreation/proposalCalldata.js b/src/features/marketCreation/proposalCalldata.js new file mode 100644 index 00000000..1382e448 --- /dev/null +++ b/src/features/marketCreation/proposalCalldata.js @@ -0,0 +1,38 @@ +// 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)', +]; + +/** + * 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/validateMetadata.js b/src/features/marketCreation/validateMetadata.js index 5c727aae..bc8c8cfe 100644 --- a/src/features/marketCreation/validateMetadata.js +++ b/src/features/marketCreation/validateMetadata.js @@ -3,8 +3,11 @@ // 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. +// 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). @@ -13,13 +16,21 @@ 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 {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. + * @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 = {}) { @@ -50,15 +61,24 @@ export function validateMetadata(metadata, ctx = {}) { 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)) { + // 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 ['yes', 'no']) { - const token0 = (t0[side] || '').toLowerCase(); - if (!token0) continue; - const expected = token0 !== company; // raw price = token1/token0; invert when company isn't token0 + 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) { @@ -67,7 +87,7 @@ export function validateMetadata(metadata, ctx = {}) { } } if (Object.keys(fixes).length) corrected = { ...m, ...fixes }; - } else if (company && ADDR.test(company)) { + } else { warnings.push('invert flags not verified — pools do not exist yet (re-run after pool creation)'); } diff --git a/test/marketCreation.test.mjs b/test/marketCreation.test.mjs index 76012aca..1e866941 100644 --- a/test/marketCreation.test.mjs +++ b/test/marketCreation.test.mjs @@ -2,33 +2,46 @@ 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 } from '../src/features/marketCreation/marketCreationWorkflow.js'; +import { buildProposalParams } from '../src/features/marketCreation/proposalCalldata.js'; -const COMPANY = '0x37b60f4e9a31a64ccc0024dce7d0fd07eaa0f7b3'; // PNK +// 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', () => { - // company IS token0 in both pools => invert should be false, matches GOOD - const r = validateMetadata(GOOD, { companyTokenAddress: COMPANY, poolToken0: { yes: COMPANY, no: COMPANY } }); + // 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 }; // but company is token0 => should be false - const r = validateMetadata(bad, { companyTokenAddress: COMPANY, poolToken0: { yes: COMPANY, no: COMPANY } }); + 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 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 +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); }); @@ -39,8 +52,27 @@ test('missing ticker is caught', () => { }); test('invert check deferred before pools exist', () => { - const r = validateMetadata(GOOD, { companyTokenAddress: COMPANY }); // no poolToken0 + 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', () => { @@ -66,8 +98,28 @@ test('floor: deep pool passes, thin pool is DRAFT', () => { 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/); +}); From 3f828b1f2360f3b7358312d91ad88a10cdd0d540 Mon Sep 17 00:00:00 2001 From: krandder Date: Sat, 26 Sep 2026 21:05:54 +0000 Subject: [PATCH 3/4] Read the new proposal address from the factory event The wizard looked for a ProposalCreated event the factory never emits, so it always fell back to the newest proposal on the shared factory. If anyone else's proposal landed first, the user was shown (and later steps would use) the wrong market. Now the address comes only from this transaction's NewProposal event, and the wizard stops with an error if it is missing. --- .../debug/hooks/useCreateProposal.js | 42 ++++----------- .../marketCreation/proposalCalldata.js | 1 + src/features/marketCreation/receiptEvents.js | 54 +++++++++++++++++++ 3 files changed, 65 insertions(+), 32 deletions(-) create mode 100644 src/features/marketCreation/receiptEvents.js diff --git a/src/components/debug/hooks/useCreateProposal.js b/src/components/debug/hooks/useCreateProposal.js index 1333cfa4..ca008923 100644 --- a/src/components/debug/hooks/useCreateProposal.js +++ b/src/components/debug/hooks/useCreateProposal.js @@ -4,6 +4,7 @@ import { useAccount, useChainId, useSwitchChain, useWalletClient, usePublicClien 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'; // ethers v5 error codes that mean the RPC transport failed (try the next @@ -199,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/features/marketCreation/proposalCalldata.js b/src/features/marketCreation/proposalCalldata.js index 1382e448..ceb2d1b0 100644 --- a/src/features/marketCreation/proposalCalldata.js +++ b/src/features/marketCreation/proposalCalldata.js @@ -7,6 +7,7 @@ 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)', ]; /** 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; +} From 30b96dd116f618fa6346a1543acec24126f5c7ba Mon Sep 17 00:00:00 2001 From: krandder Date: Sat, 26 Sep 2026 21:05:54 +0000 Subject: [PATCH 4/4] Block step 1 when the market could never go live - Send the full resolution question as marketName. It becomes the Reality.eth question on chain and cannot be changed; before, it was just "KIP-90". - Refuse to broadcast when the question is empty, lacks a question mark or has characters that break the Reality template, or when the close date is so soon that the TWAP window has already started. - Allow only one broadcast per page load, so a retry after a replaced or slow transaction cannot deploy a duplicate market. --- .../createMarket/CreateMarketFlow.jsx | 41 ++++++++++++++++++- .../marketCreation/marketCreationWorkflow.js | 26 ++++++++++++ test/marketCreation.test.mjs | 30 +++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx index 2bb588ae..efdeff75 100644 --- a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx +++ b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx @@ -8,6 +8,7 @@ import { 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'; @@ -160,9 +161,11 @@ function ExecutePanel({ form, organization }) { // 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.proposalCode, + marketName: (form.question || '').trim(), companyToken: organization.companyToken.address, currencyToken: organization.currencyToken.address, category: 'crypto', @@ -171,10 +174,31 @@ function ExecutePanel({ form, organization }) { 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; } @@ -215,7 +239,8 @@ function ExecutePanel({ form, organization }) {