Skip to content

Self-serve Create Market wizard: planner → real transactor (hub-e19) - #93

Merged
krandder merged 4 commits into
mainfrom
selfserve-wizard
Sep 26, 2026
Merged

krandder merged 4 commits into
mainfrom
selfserve-wizard

Conversation

@krandder

Copy link
Copy Markdown
Contributor

Self-serve Create Market wizard: planner → real transactor (hub-e19)

Turns /markets/new from a read-only plan preview into a wallet-connected market
creation wizard, with the ratified self-serve gates baked in. Part of the
futarchy.fi self-serve program (hub-ilc), Tier 1.

What's new

  • Real proposal creation — CreateMarketFlow now connects a wallet
    (RainbowKit) and creates the proposal via the existing, proven
    useCreateProposal hook. Simulate-first: a static call against Gnosis shows
    "would succeed — new proposal 0x…" with no broadcast, so the flow is demoable
    end-to-end without minting a market; "Sign & broadcast" sends the real tx.
  • R1 liquidity floor gate (ratified 2026-07-10) — a market goes live only if a
    $100 trade moves price < 3%; below the floor it reads DRAFT. Computed from
    pool math (src/features/marketCreation/liquidityFloor.js). With a pinhead seed
    the gate correctly says DRAFT — "a wizard that mints dead markets would be worse
    than no wizard."
  • Metadata validator (validateMetadata.js) — catches the exact bugs that
    shipped to production: TWAP invert flags disagreeing with on-chain token0
    (the ~22,000× price-display incident), missing coingecko_ticker (hidden spot
    price), and JSON that won't round-trip (the escape-stripping incident). Plus a
    read-modify-write helper so updateExtendedMetadata never drops keys.
  • R3 zero-trade honesty — the UI states that a window ending with no trades
    resolves by the default rule, not as a market verdict.
  • 0xAlex TWAP defaults — TWAP live from proposal start, 5-day (120h) window,
    48h msig buffer before vote close.
  • Static-export fix (was PR [codex] Fix create market static export #81) — /markets/new was an empty shell in
    production because the dynamic(ssr:false) chunk 404'd under output:'export'.
    Now a static import + a mounted-guard on the wallet panel, so the page renders
    in SSG and the wallet-connected parts hydrate client-side.

Tests

node --test test/marketCreation.test.mjs — 9/9: wrong invert flag caught + auto
corrected, missing ticker caught, invert deferred pre-pools, read-modify-write
preserves keys / refuses unparseable input, deep pool LIVE vs thin pool DRAFT,
zero liquidity → DRAFT.

Not in this PR (staged, shown as the plan below the wizard)

FLM bundle deploy + bootstrap, Snapshot link, and arb activation remain the
described plan steps. The FLM factory is deployed on Gnosis (0x1814aDfE…) but is
pre-audit (one medium open, hub-rslu), so wiring its bootstrap into a one-click
flow waits on that fix. Arb activation = writing arbitrageContractAddress
(shared GNO/sDAI 0xaC59…) into metadata, picked up automatically by the arb
autopilot.

@netlify

netlify Bot commented Jul 16, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for futarchy ready!

Name Link
🔨 Latest commit 30b96dd
🔍 Latest deploy log https://app.netlify.com/projects/futarchy/deploys/6ab8343d0038cd00085ddf43
😎 Deploy Preview https://deploy-preview-93--futarchy.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

category: 'crypto',
language: 'en',
minBond: form.minBondWei,
openingTime: new Date((Number(form.closeTimestamp) + 48 * 3600) * 1000).toISOString().slice(0, 16),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness, confirmed] openingTime round-trips UTC → local and shifts by the operator's timezone. toISOString().slice(0,16) produces UTC wall-clock text without a Z; new Date(text) (here in simulate, and in useCreateProposal) parses it as local time. An operator in UTC+2 gets an on-chain openingTime 2h earlier than closeTimestamp + 48h (up to ±14h depending on timezone, plus dropped seconds). Fix: keep openingTime as a unix epoch end-to-end and only format for display.

Also on this line: clearing the close-date input stores closeTimestamp = NaN, and this render-body new Date(NaN).toISOString() throws RangeError — the whole page white-screens and form input is lost. Guard invalid dates before rendering ExecutePanel.

// 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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness, confirmed] Default TWAP start is frozen at page-load time, before the pools exist. With closeTimestamp = now + 7d, this start lands exactly at page load (and startCandleUnix an hour before that). The user reviews for 10–20 min, broadcasts, and seeds pools later still — so the recorded window start predates pool initialization: Algebra getTimepoints reverts (OLD) for the window start and the candle indexer is queried for a pre-pool hour. Every wizard-default market gets a broken chart/TWAP read. Fix: anchor the window at broadcast time (or pool-seed time), not page-load, and keep the 48h-buffer relation to close as the invariant.

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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness, confirmed] Floor measures execution-price impact, ~half the real spot move. ai/(ri+ai) is the execution price impact; the post-trade spot price move in a constant-product pool is 1 − (ri/(ri+ai))² ≈ 2× larger. Example: $3,300 reserve → formula says 2.9% (LIVE), real spot move of the $100 trade is ≈5.8%. The gate passes pools at half the liquidity the R1 rule intends — exactly the thin-market state it exists to block. Fix: use the spot-move formula (and the UI copy then matches reality).

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness-when-wired, confirmed] Invert-flag rule compares the wrong token. The market page's authoritative rule (MarketPageShowcase.jsx:571) compares pool token0 against the conditional (wrapped YES/NO) company token — pools never hold the base token. This validator takes the base company token (ReadinessPanel wires selectedOrganization.companyToken.address). Once a real poolToken0 is supplied post-pool-creation, token0 can never equal the base address, so expected is always true: correct invert=false metadata gets flagged as wrong and 'corrected' to inverted — recreating the ~22,000× KIP-88/90 display bug this guard exists to prevent. The API needs per-pool conditional token addresses, not a single companyTokenAddress.

// 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';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness, confirmed] Static import + Date.now() defaults = baked build-time timestamps. With output:'export', the page's HTML now freezes close/TWAP timestamps computed at build time (rendered in the close-date field and the MetadataPreview block); hydration recomputes them at visit time. React 18 throws hydration-mismatch errors in production and users briefly see build-date timestamps that can be days stale. The mounted guard covers ExecutePanel but not these. Fix: gate the whole timestamp-bearing UI on mounted (or compute defaults inside useEffect), keeping the static-export fix.

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(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[resilience, confirmed] Simulate rides one hardcoded RPC. NEXT_PUBLIC_GNOSIS_RPC || 'https://rpc.gnosischain.com' with no fallback/retry, against the repo's 6-endpoint RPC-resilience convention. When that endpoint rate-limits, the panel shows a transport error as if the proposal would fail — pushing the operator to skip simulate and broadcast blind. Reuse the multi-RPC provider the app already ships.

Related (altitude): this block re-implements the factory ABI, param tuple, and opening-time conversion that useCreateProposal owns — simulate and broadcast can silently diverge. Expose simulateProposal(formData) from the hook so both encode provably identical calldata.

@@ -0,0 +1,73 @@
import { test } from 'node:test';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[cleanup, confirmed] Tests run nowhere. No npm script covers test/ (CI runs no tests), so floor/validator regressions ship silently; and the package is CJS-scoped, so on Node 18/20 the ESM import dies with SyntaxError. Copy the existing pattern: "test:unit": "node --test 'test/**/*.test.mjs'" like auto-qa:test:unit.

@krandder

krandder commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

The following review findings could not be attached inline (line outside the diff); posting here instead:


src/components/futarchyFi/createMarket/CreateMarketFlow.jsx:280

[correctness, confirmed] updateCloseDate still uses the 24h-era TWAP formula. This handler sets twapStartTimestamp = close − 48h, but the PR's defaults changed twapDurationHours to 120. Editing the close date therefore publishes a TWAP window [close−48h, close+72h] — ending 72h after vote close instead of 48h before it, contradicting the 0xAlex timing the defaults encode and mis-resolving any market whose close date was edited. Fix: recompute twapStartTimestamp = close − 48h − twapDurationHours·3600 here (same formula as marketCreationWorkflow.js:400), or derive it in one shared place.

@krandder

krandder commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Review pass (2026-08-06, Fable, deep multi-agent review at high effort): keep as draft — 7 confirmed correctness bugs need fixes before this is merge-ready. They cluster in the timing math, which is exactly the part that resolves markets:

  1. updateCloseDate still uses the 24h-era formula → edited close dates publish a TWAP window ending 72h after vote close (inline comment above).
  2. openingTime UTC→local round-trip shifts the on-chain value by the operator's timezone.
  3. Default TWAP start is frozen at page-load time, predating pool creation → broken chart/TWAP reads for every wizard-default market.
  4. Liquidity floor measures execution impact, ~half the real spot move → LIVE at half the intended liquidity.
  5. Invert-flag validator compares the base token where the authoritative rule uses the conditional token → would recreate the KIP-88/90 inversion bug once wired to real pools.
  6. Clearing the close-date field NaN-crashes the page.
  7. Static import + Date.now() defaults bake build-time timestamps into the exported HTML (hydration mismatch).

Plus three cleanups (single-RPC simulate, duplicated factory ABI between simulate and broadcast, tests wired to no npm script). One candidate was refuted in verification (the from: 0xdEaD static-call concern — the factory's createProposal path doesn't gate on sender at simulate depth).

The architecture is right — simulate-first transactor, floor gate, honesty copy, read-modify-write metadata guard all match the ratified design. The fixes are well-specified and mechanical; fix package to follow on this branch.

@krandder

krandder commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Fix package landed as 4380afd — all 10 findings from the review above are addressed:

# Finding Fix
1 updateCloseDate 24h-era formula (window ends 72h after close) shared deriveTwapTiming() — one derivation for defaults and edits
2 openingTime UTC→local timezone shift epoch seconds end-to-end (openingTimeUnix), REALITY_OPENING_BUFFER_SECONDS
3 Default TWAP start frozen at page-load (predates pools) validator errors when window starts >15 min in the past; defaults computed at client mount; step 2 must recompute at write time
4 Floor ~2× too lenient (execution vs spot impact) 1−(ri/(ri+ai))²; $3,300-reserve case now reads DRAFT (regression test)
5 Invert validator compared base token per-pool {token0, conditionalCompanyToken} ctx, matching MarketPageShowcase's rule; defers with warning pre-pools
6 NaN white-screen on cleared close date stores null, disables actions with a hint
7 Build-time timestamps in exported HTML / hydration mismatch form defaults init in useEffect; export verified: wizard markup present, zero embedded timestamps
8 Single hardcoded simulate RPC simulateProposal iterates getRpcUrls(100); transport ≠ revert
9 Simulate duplicated factory ABI/params shared proposalCalldata.js feeds both simulate and broadcast
10 Tests wired to nothing npm run test:unit — 13/13 pass

Proof: npm run test:unit 13/13 · next build green · exported /markets/new contains all four wizard headings and no 17xxxxxxxx epoch strings.

Still deliberately NOT in this PR (per hub-e19 scope): metadata write + pool creation (wizard steps 2–3, next increment), FLM bootstrap (gated on hub-rslu), arb activation. Ready for Kelvin's review.

…dator (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
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.
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.
- 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.
@krandder

Copy link
Copy Markdown
Contributor Author

Review (2026-09-26): APPROVE after the fixes pushed today. (Posted as a comment because GitHub does not let the author approve their own PR.)

This PR sends a real transaction from the user's wallet: createProposal on the Gnosis futarchy factory. I checked what can go wrong for the user. There are no token approvals and no funds moved in this step. The chain is fixed to Gnosis (100) and checked again before signing. Simulate and broadcast build the same calldata.

P1, fixed on the branch

  1. Wrong on-chain question. The wizard sent marketName: "KIP-90". The factory turns marketName into the Reality.eth question, which can never be changed, so answerers could call the market Invalid. It now sends the full resolution question. (30b96dd)
  2. A market that could never go live. Nothing stopped a broadcast when the close date was in the past or too soon. The TWAP window would already have started, and step 2's validator would then refuse the metadata, leaving a stranded proposal that the user paid gas for. Broadcast is now blocked in that case, and also when the question is empty, lacks a "?", or has characters that break the Reality template. (30b96dd)
  3. Wrong proposal shown after creation. The hook looked for a ProposalCreated event that the factory never emits, so it always fell back to "the newest proposal on the shared factory". The address now comes only from this transaction's NewProposal event. If that event is missing, the wizard stops with an error. (3f828b1)

Also fixed: only one broadcast is allowed per page load, so a retry after a slow or replaced transaction cannot deploy a duplicate market. I also re-authored the first commit, which credited an AI tool as author; the tree is unchanged.

P2, still open (not blocking)

  • Signing goes through window.ethereum, not the connected wagmi wallet. A WalletConnect user who also has a browser wallet may be asked to sign from the other wallet. There is no wrong-chain risk, because the chain is checked.
  • The readiness panel (liquidity floor, metadata checks) is display-only for step 1. The hard timing and question checks above now gate broadcast.
  • The validator's "now" is memoized on the draft, so a page left open does not refresh that warning. The broadcast gate itself re-checks at click time.

Tests: 15/15 unit, 695/695 deterministic auto-QA, and lint on the changed files all pass, and next build succeeds. The branch sits on current main (cdc00e2). Preview: https://deploy-preview-93--futarchy.netlify.app/markets/new

@krandder
krandder merged commit 7b75a5f into main Sep 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant