Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 185 additions & 17 deletions src/components/futarchyFi/createMarket/CreateMarketFlow.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 (
<section className={`${panelClass} p-4`}>
<h2 className="text-lg font-semibold text-futarchyGray12 dark:text-white">Readiness gate</h2>
<p className="mt-1 text-sm text-futarchyGray11">
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.
</p>

<div className="mt-4 rounded-md border border-futarchyGray6 dark:border-futarchyGray7 p-3">
<div className="flex items-center justify-between">
<span className={labelClass}>Liquidity floor</span>
<span className={`text-sm font-semibold ${badge(floor.passes)}`}>
{floor.state}
</span>
</div>
<p className="mt-1 text-xs text-futarchyGray10">{floor.reason} (from the planned seed).</p>
</div>

<div className="mt-3 rounded-md border border-futarchyGray6 dark:border-futarchyGray7 p-3">
<div className="flex items-center justify-between">
<span className={labelClass}>Metadata</span>
<span className={`text-sm font-semibold ${badge(validation.ok)}`}>
{validation.ok ? 'Valid' : `${validation.errors.length} issue${validation.errors.length === 1 ? '' : 's'}`}
</span>
</div>
{validation.errors.map((e, i) => (
<p key={i} className="mt-1 text-xs text-amber-600 dark:text-amber-400">• {e}</p>
))}
{validation.warnings.map((w, i) => (
<p key={i} className="mt-1 text-xs text-futarchyGray10">• {w}</p>
))}
</div>

<p className="mt-3 text-xs text-futarchyGray9 italic">{ZERO_TRADE_NOTICE}</p>
</section>
);
}

// 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 (
<section className={`${panelClass} p-4`}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-futarchyGray12 dark:text-white">Create proposal</h2>
<ConnectButton showBalance={false} chainStatus="icon" accountStatus="address" />
</div>
<p className="mt-1 text-sm text-futarchyGray11">
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.
</p>

<div className="mt-4 flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-md border border-futarchyGray6 dark:border-futarchyGray7 p-0.5">
{['simulate', 'broadcast'].map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-3 py-1.5 text-sm rounded ${mode === m
? 'bg-futarchyBlue9 text-white'
: 'text-futarchyGray11'}`}
>
{m === 'simulate' ? 'Simulate' : 'Sign & broadcast'}
</button>
))}
</div>
<button
onClick={onRun}
disabled={isSubmitting || (mode === 'broadcast' && !isConnected)}
className="inline-flex h-9 items-center rounded-md bg-futarchyBlue9 px-4 text-sm font-medium text-white disabled:opacity-50"
>
{isSubmitting ? 'Working…' : mode === 'simulate' ? 'Simulate createProposal' : 'Create proposal'}
</button>
{mode === 'broadcast' && !isConnected && (
<span className="text-xs text-amber-600 dark:text-amber-400">Connect a wallet to broadcast.</span>
)}
</div>

{simResult && (
<p className={`mt-3 text-sm ${simResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'}`}>
{simResult.msg}
</p>
)}
{status && (
<p className="mt-3 text-sm text-futarchyGray11">{status.message}</p>
)}
{transactionHash && (
<p className="mt-1 text-xs font-mono text-futarchyBlue9 break-all">tx: {transactionHash}</p>
)}
{proposalAddress && (
<p className="mt-1 text-xs font-mono text-emerald-600 dark:text-emerald-400 break-all">proposal: {proposalAddress}</p>
)}
</section>
);
}

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 }),
Expand All @@ -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);
Expand Down Expand Up @@ -135,20 +302,21 @@ export default function CreateMarketFlow() {
</Link>
</div>

<section className={`${panelClass} mb-6`}>
<div className="border-b border-futarchyGray6 px-4 py-3 dark:border-futarchyGray7">
<h2 className="text-lg font-semibold text-futarchyGray12 dark:text-white">Permissionless Chiado Stack</h2>
<p className="mt-1 text-sm text-futarchyGray11">
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.
</p>
</div>
<StageList stages={permissionlessPlan.stages} />
<div className="border-t border-futarchyGray6 px-4 py-3 dark:border-futarchyGray7">
<h3 className="text-sm font-semibold text-futarchyGray12 dark:text-white">Contract Actions</h3>
</div>
<ActionList actions={permissionlessPlan.contractActions} />
</section>
<div className="grid gap-6 lg:grid-cols-2 mb-6">
{mounted ? (
<ExecutePanel form={form} organization={selectedOrganization} />
) : (
<section className={`${panelClass} p-4`}>
<h2 className="text-lg font-semibold text-futarchyGray12 dark:text-white">Create proposal</h2>
<p className="mt-1 text-sm text-futarchyGray11">Loading wallet…</p>
</section>
)}
<ReadinessPanel
metadataDraft={marketPlan.metadataDraft}
companyTokenAddress={selectedOrganization.companyToken.address}
bootstrap={form.initialLiquidityBudget}
/>
</div>

<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
<section className={`${panelClass} p-4`}>
Expand Down
56 changes: 56 additions & 0 deletions src/features/marketCreation/liquidityFloor.js
Original file line number Diff line number Diff line change
@@ -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.';
8 changes: 6 additions & 2 deletions src/features/marketCreation/marketCreationWorkflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading