Skip to content

Commit 3ed59ae

Browse files
committed
Add permissionless market creation flow
1 parent 239180f commit 3ed59ae

5 files changed

Lines changed: 683 additions & 10 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { readFile } from 'node:fs/promises';
4+
import { dirname, resolve } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const here = dirname(fileURLToPath(import.meta.url));
8+
const sourcePath = resolve(here, '../../src/features/marketCreation/marketCreationWorkflow.js');
9+
const source = await readFile(sourcePath, 'utf8');
10+
const workflow = await import(`data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`);
11+
12+
const {
13+
buildMetadataDraft,
14+
buildOneStepMarketPlan,
15+
buildPermissionlessStackPlan,
16+
createMarketWizardDefaults,
17+
KNOWN_ORGANIZATIONS,
18+
MARKET_CREATION_STAGES,
19+
PERMISSIONLESS_STACK_STAGES,
20+
validateOneStepMarketPlan,
21+
} = workflow;
22+
23+
const NOW = 1_782_777_600;
24+
25+
test('Kleros defaults use KIP, PNK, sDAI, and FLM liquidity', () => {
26+
const defaults = createMarketWizardDefaults({ organizationId: 'kleros', nowSeconds: NOW });
27+
28+
assert.equal(defaults.organizationName, 'Kleros DAO');
29+
assert.equal(defaults.proposalCode, 'KIP-90');
30+
assert.equal(defaults.companyToken.symbol, 'PNK');
31+
assert.equal(defaults.currencyToken.symbol, 'sDAI');
32+
assert.equal(defaults.initialLiquidityMode, 'flm');
33+
assert.equal(defaults.snapshotLinkAfterLiquidity, true);
34+
});
35+
36+
test('Gnosis defaults use GIP, GNO, sDAI, and Gnosis Snapshot space', () => {
37+
const defaults = createMarketWizardDefaults({ organizationId: 'gnosis', nowSeconds: NOW });
38+
39+
assert.equal(defaults.organizationName, 'Gnosis DAO');
40+
assert.equal(defaults.proposalCode, 'GIP-151');
41+
assert.equal(defaults.companyToken.symbol, 'GNO');
42+
assert.equal(defaults.currencyToken.symbol, 'sDAI');
43+
assert.equal(KNOWN_ORGANIZATIONS.gnosis.snapshotSpace, 'gnosis.eth');
44+
});
45+
46+
test('one-step market plan covers the operational stages in required order', () => {
47+
const plan = buildOneStepMarketPlan({ organizationId: 'kleros', nowSeconds: NOW });
48+
const stageIds = plan.stages.map((stage) => stage.id);
49+
50+
assert.deepEqual(stageIds, MARKET_CREATION_STAGES.map((stage) => stage.id));
51+
assert.ok(stageIds.indexOf('liquidity') < stageIds.indexOf('snapshot'));
52+
assert.ok(stageIds.includes('metadata'));
53+
assert.ok(stageIds.includes('indexing'));
54+
assert.ok(stageIds.includes('arbitrage'));
55+
assert.ok(stageIds.includes('publish'));
56+
});
57+
58+
test('metadata draft includes registry fields needed by market pages and proposal routing', () => {
59+
const draft = buildMetadataDraft({
60+
organizationId: 'kleros',
61+
nowSeconds: NOW,
62+
snapshotId: '0xba2749a4f1283da9d1ca925d9f17bf712fa06a23e6a07d759c54340277820932',
63+
});
64+
65+
assert.equal(draft.chain, 100);
66+
assert.equal(draft.snapshot_id, '0xba2749a4f1283da9d1ca925d9f17bf712fa06a23e6a07d759c54340277820932');
67+
assert.equal(draft.resolution_status, 'unresolved');
68+
assert.equal(draft.visibility, 'public');
69+
assert.equal(draft.companyTokens.base.tokenSymbol, 'PNK');
70+
assert.equal(draft.currencyTokens.base.tokenSymbol, 'sDAI');
71+
assert.equal(draft.flm.mode, 'flm');
72+
});
73+
74+
test('permissionless stack plan includes org listing, owner proposals, and default FLM', () => {
75+
const plan = buildPermissionlessStackPlan();
76+
const stageIds = plan.stages.map((stage) => stage.id);
77+
78+
assert.deepEqual(stageIds, PERMISSIONLESS_STACK_STAGES.map((stage) => stage.id));
79+
assert.ok(stageIds.includes('create-organization'));
80+
assert.ok(stageIds.includes('list-organization'));
81+
assert.ok(stageIds.includes('default-flm'));
82+
assert.ok(stageIds.includes('owner-proposal'));
83+
assert.equal(plan.values.chainId, 10200);
84+
});
85+
86+
test('validation rejects flows that would link Snapshot before liquidity', () => {
87+
const validPlan = buildOneStepMarketPlan({ organizationId: 'gnosis', nowSeconds: NOW });
88+
assert.equal(validateOneStepMarketPlan(validPlan, { nowSeconds: NOW }).ok, true);
89+
90+
const invalidPlan = buildOneStepMarketPlan({
91+
organizationId: 'gnosis',
92+
nowSeconds: NOW,
93+
snapshotLinkAfterLiquidity: false,
94+
});
95+
assert.deepEqual(validateOneStepMarketPlan(invalidPlan).errors, ['snapshotLinkAfterLiquidity']);
96+
});

src/components/futarchyFi/companyList/page/CompaniesPage.jsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useState, useEffect } from "react";
2+
import Link from "next/link";
23
import { useAccount } from 'wagmi';
34
import RootLayout from "../../../layout/RootLayout";
45
import { fetchEventHighlightData } from "./EventsHighlightDataTransformer";
@@ -122,10 +123,16 @@ const CompaniesPage = ({ useStorybookUrl = false }) => {
122123
)}
123124

124125
{/* Organizations Section Header */}
125-
<div className="mb-6 mt-12">
126-
<h2 className="text-2xl font-semibold text-futarchyGray12 dark:text-white mb-6">
126+
<div className="mb-6 mt-12 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
127+
<h2 className="text-2xl font-semibold text-futarchyGray12 dark:text-white">
127128
Organizations
128129
</h2>
130+
<Link
131+
href="/markets/new"
132+
className="inline-flex h-10 items-center justify-center rounded-md border border-futarchyGray6 px-4 text-sm font-medium text-futarchyGray12 hover:border-futarchyBlue9 hover:text-futarchyBlue10 dark:border-futarchyGray7 dark:text-white"
133+
>
134+
Create market
135+
</Link>
129136
</div>
130137

131138
{/* Organizations Table (Desktop) */}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import React, { useMemo, useState } from 'react';
2+
import Link from 'next/link';
3+
import {
4+
buildOneStepMarketPlan,
5+
buildPermissionlessStackPlan,
6+
createMarketWizardDefaults,
7+
KNOWN_ORGANIZATIONS,
8+
} from '../../../features/marketCreation/marketCreationWorkflow';
9+
import RootLayout from '../../layout/RootLayout';
10+
import PageLayout from '../../layout/PageLayout';
11+
12+
const panelClass = 'border border-futarchyGray6 dark:border-futarchyGray7 bg-white dark:bg-futarchyGray2 rounded-lg';
13+
const inputClass = 'w-full px-3 py-2 bg-futarchyGray2 dark:bg-futarchyGray3 border border-futarchyGray6 dark:border-futarchyGray7 rounded-md text-sm text-futarchyGray12 dark:text-white focus:outline-none focus:ring-2 focus:ring-futarchyBlue9';
14+
const labelClass = 'text-xs font-semibold uppercase tracking-wide text-futarchyGray10 dark:text-futarchyGray11';
15+
16+
function formatDate(timestamp) {
17+
if (!timestamp) return 'Not set';
18+
return new Date(Number(timestamp) * 1000).toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
19+
}
20+
21+
function StageList({ stages }) {
22+
return (
23+
<ol className="divide-y divide-futarchyGray5 dark:divide-futarchyGray7">
24+
{stages.map((stage) => (
25+
<li key={stage.id} className="grid gap-2 md:grid-cols-[48px_220px_1fr] px-4 py-3">
26+
<div className="text-sm font-semibold text-futarchyBlue9">{String(stage.order).padStart(2, '0')}</div>
27+
<div>
28+
<div className="text-sm font-semibold text-futarchyGray12 dark:text-white">{stage.title}</div>
29+
{stage.dependsOn?.length ? (
30+
<div className="mt-1 text-xs text-futarchyGray10">After: {stage.dependsOn.join(', ')}</div>
31+
) : null}
32+
</div>
33+
<div>
34+
<p className="text-sm text-futarchyGray11 dark:text-futarchyGray11">{stage.summary}</p>
35+
{stage.requiredEvidence?.length ? (
36+
<p className="mt-1 text-xs text-futarchyGray9">
37+
Evidence: {stage.requiredEvidence.join(', ')}
38+
</p>
39+
) : null}
40+
</div>
41+
</li>
42+
))}
43+
</ol>
44+
);
45+
}
46+
47+
function MetadataPreview({ metadata }) {
48+
return (
49+
<pre className="max-h-[420px] overflow-auto rounded-md bg-futarchyGray2 dark:bg-futarchyDarkGray3 border border-futarchyGray6 dark:border-futarchyGray7 p-4 text-xs text-futarchyGray12 dark:text-futarchyGray11">
50+
{JSON.stringify(metadata, null, 2)}
51+
</pre>
52+
);
53+
}
54+
55+
export default function CreateMarketFlow() {
56+
const [organizationId, setOrganizationId] = useState('kleros');
57+
const defaults = useMemo(
58+
() => createMarketWizardDefaults({ organizationId }),
59+
[organizationId]
60+
);
61+
const [form, setForm] = useState(defaults);
62+
63+
const selectedOrganization = KNOWN_ORGANIZATIONS[organizationId];
64+
const marketPlan = useMemo(() => buildOneStepMarketPlan({ ...form, organizationId }), [form, organizationId]);
65+
const permissionlessPlan = useMemo(() => buildPermissionlessStackPlan(), []);
66+
67+
const updateOrganization = (nextOrganizationId) => {
68+
setOrganizationId(nextOrganizationId);
69+
setForm(createMarketWizardDefaults({ organizationId: nextOrganizationId }));
70+
};
71+
72+
const updateField = (field, value) => {
73+
setForm((previous) => ({ ...previous, [field]: value }));
74+
};
75+
76+
const updateCloseDate = (value) => {
77+
const nextTimestamp = Math.floor(new Date(value).getTime() / 1000);
78+
setForm((previous) => ({
79+
...previous,
80+
closeDateTimeLocal: value,
81+
closeTimestamp: nextTimestamp,
82+
twapStartTimestamp: nextTimestamp - (48 * 60 * 60),
83+
startCandleUnix: nextTimestamp - (49 * 60 * 60),
84+
}));
85+
};
86+
87+
return (
88+
<RootLayout headerConfig="app" footerConfig="main">
89+
<PageLayout contentClassName="max-w-7xl">
90+
<div className="py-8">
91+
<div className="mb-8 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
92+
<div>
93+
<h1 className="text-2xl font-semibold text-futarchyGray12 dark:text-white">Create Market</h1>
94+
<p className="mt-2 max-w-3xl text-sm text-futarchyGray11 dark:text-futarchyGray11">
95+
A single operational flow for organization setup, proposal metadata, market creation,
96+
FLM liquidity, Snapshot linking, candle readiness, arbitrage setup, and publishing.
97+
</p>
98+
</div>
99+
<Link
100+
href="/companies"
101+
className="inline-flex h-10 items-center justify-center rounded-md border border-futarchyGray6 px-4 text-sm font-medium text-futarchyGray12 dark:border-futarchyGray7 dark:text-white"
102+
>
103+
Companies
104+
</Link>
105+
</div>
106+
107+
<section className={`${panelClass} mb-6`}>
108+
<div className="border-b border-futarchyGray6 px-4 py-3 dark:border-futarchyGray7">
109+
<h2 className="text-lg font-semibold text-futarchyGray12 dark:text-white">Permissionless Chiado Stack</h2>
110+
<p className="mt-1 text-sm text-futarchyGray11">
111+
This is the target testnet lifecycle: any wallet creates an organization, it is listed
112+
automatically, and the organization receives a default FLM for proposal liquidity.
113+
</p>
114+
</div>
115+
<StageList stages={permissionlessPlan.stages} />
116+
</section>
117+
118+
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
119+
<section className={`${panelClass} p-4`}>
120+
<h2 className="text-lg font-semibold text-futarchyGray12 dark:text-white">Market Defaults</h2>
121+
122+
<div className="mt-5 space-y-4">
123+
<div>
124+
<label className={labelClass} htmlFor="organization">Organization</label>
125+
<select
126+
id="organization"
127+
className={`${inputClass} mt-1`}
128+
value={organizationId}
129+
onChange={(event) => updateOrganization(event.target.value)}
130+
>
131+
{Object.values(KNOWN_ORGANIZATIONS).map((org) => (
132+
<option key={org.id} value={org.id}>{org.name}</option>
133+
))}
134+
</select>
135+
</div>
136+
137+
<div>
138+
<label className={labelClass} htmlFor="proposalCode">Proposal Code</label>
139+
<input
140+
id="proposalCode"
141+
className={`${inputClass} mt-1`}
142+
value={form.proposalCode}
143+
onChange={(event) => updateField('proposalCode', event.target.value)}
144+
/>
145+
</div>
146+
147+
<div>
148+
<label className={labelClass} htmlFor="displayTitle0">Display Title</label>
149+
<input
150+
id="displayTitle0"
151+
className={`${inputClass} mt-1`}
152+
value={form.displayTitle0}
153+
onChange={(event) => updateField('displayTitle0', event.target.value)}
154+
/>
155+
<input
156+
aria-label="Display title event"
157+
className={`${inputClass} mt-2`}
158+
value={form.displayTitle1}
159+
onChange={(event) => updateField('displayTitle1', event.target.value)}
160+
/>
161+
</div>
162+
163+
<div>
164+
<label className={labelClass} htmlFor="question">Resolution Question</label>
165+
<textarea
166+
id="question"
167+
className={`${inputClass} mt-1 min-h-[88px]`}
168+
value={form.question}
169+
onChange={(event) => updateField('question', event.target.value)}
170+
/>
171+
</div>
172+
173+
<div>
174+
<label className={labelClass} htmlFor="snapshotId">Snapshot Proposal Hash</label>
175+
<input
176+
id="snapshotId"
177+
className={`${inputClass} mt-1 font-mono`}
178+
placeholder="0x..."
179+
value={form.snapshotId}
180+
onChange={(event) => updateField('snapshotId', event.target.value)}
181+
/>
182+
</div>
183+
184+
<div>
185+
<label className={labelClass} htmlFor="closeDate">Vote Close Time</label>
186+
<input
187+
id="closeDate"
188+
type="datetime-local"
189+
className={`${inputClass} mt-1`}
190+
value={form.closeDateTimeLocal}
191+
onChange={(event) => updateCloseDate(event.target.value)}
192+
/>
193+
</div>
194+
195+
<div className="grid grid-cols-2 gap-3">
196+
<div>
197+
<div className={labelClass}>Company Token</div>
198+
<div className="mt-1 rounded-md border border-futarchyGray6 p-3 text-sm dark:border-futarchyGray7">
199+
<div className="font-semibold text-futarchyGray12 dark:text-white">{selectedOrganization.companyToken.symbol}</div>
200+
<div className="mt-1 break-all font-mono text-xs text-futarchyGray10">{selectedOrganization.companyToken.address}</div>
201+
</div>
202+
</div>
203+
<div>
204+
<div className={labelClass}>Currency Token</div>
205+
<div className="mt-1 rounded-md border border-futarchyGray6 p-3 text-sm dark:border-futarchyGray7">
206+
<div className="font-semibold text-futarchyGray12 dark:text-white">{selectedOrganization.currencyToken.symbol}</div>
207+
<div className="mt-1 break-all font-mono text-xs text-futarchyGray10">{selectedOrganization.currencyToken.address}</div>
208+
</div>
209+
</div>
210+
</div>
211+
</div>
212+
</section>
213+
214+
<section className={`${panelClass}`}>
215+
<div className="border-b border-futarchyGray6 px-4 py-3 dark:border-futarchyGray7">
216+
<h2 className="text-lg font-semibold text-futarchyGray12 dark:text-white">One-Step Execution Plan</h2>
217+
<div className="mt-2 grid gap-2 text-xs text-futarchyGray10 md:grid-cols-3">
218+
<span>Org: {marketPlan.values.organizationName}</span>
219+
<span>Close: {formatDate(marketPlan.values.closeTimestamp)}</span>
220+
<span>Liquidity: {marketPlan.values.initialLiquidityMode.toUpperCase()}</span>
221+
</div>
222+
</div>
223+
<StageList stages={marketPlan.stages} />
224+
</section>
225+
</div>
226+
227+
<section className={`${panelClass} mt-6 p-4`}>
228+
<div className="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
229+
<div>
230+
<h2 className="text-lg font-semibold text-futarchyGray12 dark:text-white">Generated Metadata Draft</h2>
231+
<p className="mt-1 text-sm text-futarchyGray11">
232+
This is the registry metadata shape the one-step flow will pass into the market
233+
creation and proposal metadata writes.
234+
</p>
235+
</div>
236+
<Link
237+
href={`/milestones?company_id=${selectedOrganization.organizationAddress}`}
238+
className="inline-flex h-9 items-center justify-center rounded-md border border-futarchyGray6 px-3 text-sm text-futarchyGray12 dark:border-futarchyGray7 dark:text-white"
239+
>
240+
Open organization
241+
</Link>
242+
</div>
243+
<MetadataPreview metadata={marketPlan.metadataDraft} />
244+
</section>
245+
</div>
246+
</PageLayout>
247+
</RootLayout>
248+
);
249+
}

0 commit comments

Comments
 (0)