diff --git a/.github/workflows/auto-qa.yml b/.github/workflows/auto-qa.yml index a75cfc1a..4485798a 100644 --- a/.github/workflows/auto-qa.yml +++ b/.github/workflows/auto-qa.yml @@ -7,7 +7,9 @@ on: - '.github/workflows/auto-qa-live.yml' - 'auto-qa/**' - 'package.json' + - 'src/features/marketCreation/**' - 'src/utils/proposalLifecycle.js' + - 'test/**' push: branches: - main @@ -16,7 +18,9 @@ on: - '.github/workflows/auto-qa-live.yml' - 'auto-qa/**' - 'package.json' + - 'src/features/marketCreation/**' - 'src/utils/proposalLifecycle.js' + - 'test/**' workflow_dispatch: jobs: diff --git a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx index efdeff75..c6f39eb2 100644 --- a/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx +++ b/src/components/futarchyFi/createMarket/CreateMarketFlow.jsx @@ -1,5 +1,6 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; +import { useRouter } from 'next/router'; import { ConnectButton } from '@rainbow-me/rainbowkit'; import { buildOneStepMarketPlan, @@ -11,8 +12,10 @@ import { stepOneBroadcastErrors, } from '../../../features/marketCreation/marketCreationWorkflow'; import { validateMetadata } from '../../../features/marketCreation/validateMetadata'; +import { fetchSnapshotVoteEnd } from '../../../features/marketCreation/snapshotTiming'; import { evaluateFloor, ZERO_TRADE_NOTICE, FLOOR_TRADE_USD, FLOOR_MAX_IMPACT } from '../../../features/marketCreation/liquidityFloor'; import useCreateProposal, { simulateProposal } from '../../debug/hooks/useCreateProposal'; +import WizardSteps23 from './WizardSteps23'; import RootLayout from '../../layout/RootLayout'; import PageLayout from '../../layout/PageLayout'; @@ -152,8 +155,11 @@ function ReadinessPanel({ metadataDraft, bootstrap }) { // 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 }) { +function ExecutePanel({ form, organization, onProposalCreated }) { const { isConnected, isSubmitting, status, transactionHash, proposalAddress, createProposal } = useCreateProposal(); + useEffect(() => { + if (proposalAddress) onProposalCreated(proposalAddress); + }, [proposalAddress, onProposalCreated]); const [mode, setMode] = useState('simulate'); const [simResult, setSimResult] = useState(null); const [isSimulating, setIsSimulating] = useState(false); @@ -284,15 +290,45 @@ function ExecutePanel({ form, organization }) { } export default function CreateMarketFlow() { + const router = useRouter(); const [organizationId, setOrganizationId] = useState('kleros'); + // Steps 2–3 unlock once a proposal exists; ?proposal=0x… resumes after a + // refresh instead of losing progress. + const [proposalAddress, setProposalAddress] = useState(null); + const [proposalOrganizationId, setProposalOrganizationId] = useState(null); + useEffect(() => { + const q = router.query?.proposal; + if (typeof q !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(q)) return; + const queryOrganization = typeof router.query?.organization === 'string' + && KNOWN_ORGANIZATIONS[router.query.organization] + ? router.query.organization + : null; + setProposalAddress(q); + setProposalOrganizationId(queryOrganization); + if (queryOrganization && queryOrganization !== organizationId) { + setOrganizationId(queryOrganization); + setForm(createMarketWizardDefaults({ organizationId: queryOrganization })); + } + }, [router.query, organizationId]); + const onProposalCreated = useCallback((address) => { + setProposalAddress(address); + setProposalOrganizationId(organizationId); + router.replace({ query: { ...router.query, proposal: address, organization: organizationId } }, undefined, { shallow: true }); + }, [organizationId, router]); // 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' })); - }, []); + if (!router.isReady) return; + const queryOrganization = typeof router.query?.organization === 'string' + && KNOWN_ORGANIZATIONS[router.query.organization] + ? router.query.organization + : 'kleros'; + setOrganizationId(queryOrganization); + setForm(createMarketWizardDefaults({ organizationId: queryOrganization })); + }, [router.isReady, router.query?.organization]); const selectedOrganization = KNOWN_ORGANIZATIONS[organizationId]; const marketPlan = useMemo( @@ -301,12 +337,33 @@ export default function CreateMarketFlow() { ); const updateOrganization = (nextOrganizationId) => { + if (proposalAddress) return; setOrganizationId(nextOrganizationId); setForm(createMarketWizardDefaults({ organizationId: nextOrganizationId })); }; + const [snapshotNote, setSnapshotNote] = useState(null); + const updateField = (field, value) => { setForm((previous) => ({ ...previous, [field]: value })); + // Increment D: a pasted Snapshot hash autofills the close time from the + // real vote end, anchoring the whole TWAP window to it. + if (field === 'snapshotId') { + setSnapshotNote(null); + fetchSnapshotVoteEnd(value).then((vote) => { + if (!vote) return; + setForm((previous) => { + if (previous?.snapshotId !== value) return previous; // stale response + return { + ...previous, + closeTimestamp: vote.end, + closeDateTimeLocal: new Date(vote.end * 1000).toISOString().slice(0, 16), + ...deriveTwapTiming(vote.end, previous.twapDurationHours), + }; + }); + setSnapshotNote(`Close time autofilled from the Snapshot vote end (${vote.state}).`); + }); + } }; const updateCloseDate = (value) => { @@ -358,14 +415,31 @@ export default function CreateMarketFlow() { ) : ( <> +
+ Step 1 {proposalAddress ? '✓' : '·'} create proposal → Step 2 · metadata → Step 3 · pools + invert check +
- +
+ {proposalAddress && proposalOrganizationId === organizationId && ( + + )} + {proposalAddress && (!proposalOrganizationId || proposalOrganizationId !== organizationId) && ( +

+ This proposal is not safely bound to the selected organization. Restart the wizard instead of writing mismatched metadata. +

+ )} +

Market Defaults

@@ -377,12 +451,18 @@ export default function CreateMarketFlow() { id="organization" className={`${inputClass} mt-1`} value={organizationId} + disabled={Boolean(proposalAddress)} onChange={(event) => updateOrganization(event.target.value)} > {Object.values(KNOWN_ORGANIZATIONS).map((org) => ( ))} + {proposalAddress && ( +

+ Organization is locked after proposal creation so metadata cannot be written to a different organization. +

+ )}
@@ -430,6 +510,9 @@ export default function CreateMarketFlow() { value={form.snapshotId} onChange={(event) => updateField('snapshotId', event.target.value)} /> + {snapshotNote && ( +

{snapshotNote}

+ )}
diff --git a/src/components/futarchyFi/createMarket/WizardSteps23.jsx b/src/components/futarchyFi/createMarket/WizardSteps23.jsx new file mode 100644 index 00000000..bee8acc9 --- /dev/null +++ b/src/components/futarchyFi/createMarket/WizardSteps23.jsx @@ -0,0 +1,499 @@ +// Wizard steps 2–3: metadata write via the org editor key, pool creation from +// the proposal's conditional tokens, then the invert-flag verification pass +// against real on-chain token0 — the gate that makes the KIP-88/90 inverted +// price bug unrepresentable. Spec: hub docs/specs/2026-08-06-selfserve-wizard-increment-C.md +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAccount, useChainId, usePublicClient, useSwitchChain, useWriteContract } from 'wagmi'; +import { CONTRACT_ABIS } from '../marketPage/constants/contracts'; +import { + buildMetadataDraft, + deriveTwapTiming, + GNOSIS_CHAIN_ID, + REALITY_OPENING_BUFFER_SECONDS, +} from '../../../features/marketCreation/marketCreationWorkflow'; +import { validateMetadata, mergeMetadataForUpdate } from '../../../features/marketCreation/validateMetadata'; +import { + buildProposalMetadataArgs, + buildInvertContext, + buildInvertPatch, + bindProposalToOrganization, +} from '../../../features/marketCreation/orgMetadataWrite'; +import { fetchProposalFromChain } from '../../../adapters/subgraphConfigAdapter'; +import { useCreatePool } from '../../../hooks/useCreatePool'; +import { ensureWalletChain, readInjectedWalletChainId } from '../../../features/marketCreation/chainGuard'; +import { findCreatedMetadataAddress } from '../../../features/marketCreation/receiptEvents'; +import { nextPoolPublication } from '../../../features/marketCreation/poolPublication'; + +const panelClass = 'border border-futarchyGray6 dark:border-futarchyGray7 bg-white dark:bg-futarchyGray2 rounded-lg'; +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'; +const btnClass = 'inline-flex h-9 items-center rounded-md bg-futarchyBlue9 px-4 text-sm font-medium text-white disabled:opacity-50'; +const ADDR = /^0x[a-fA-F0-9]{40}$/; + +const PROPOSAL_BINDING_ABI = [ + { name: 'marketName', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] }, + { name: 'collateralToken1', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] }, + { name: 'collateralToken2', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] }, + { name: 'questionId', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'bytes32' }] }, + { name: 'realityProxy', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] }, +]; +const REALITY_PROXY_ABI = [ + { name: 'realitio', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] }, +]; +const REALITIO_ABI = [ + { name: 'getOpeningTS', type: 'function', stateMutability: 'view', inputs: [{ type: 'bytes32' }], outputs: [{ type: 'uint32' }] }, +]; + +const TOKEN0_ABI = [ + { name: 'token0', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] }, +]; + +function StatusLine({ tone = 'info', children }) { + const cls = tone === 'ok' + ? 'text-emerald-600 dark:text-emerald-400' + : tone === 'warn' ? 'text-amber-600 dark:text-amber-400' : 'text-futarchyGray11'; + return

{children}

; +} + +// --------------------------------------------------------------------------- +// Step 2 — metadata write via the org editor key +// --------------------------------------------------------------------------- +export function MetadataWritePanel({ proposalAddress, form, organization, organizationId, metadataAddress, onMetadataContract }) { + const { address: account, isConnected } = useAccount(); + const currentChainId = useChainId(); + const { switchChainAsync } = useSwitchChain(); + const publicClient = usePublicClient({ chainId: GNOSIS_CHAIN_ID }); + const { writeContractAsync } = useWriteContract(); + const [roles, setRoles] = useState(null); // { owner, editor } | null + const [phase, setPhase] = useState('idle'); // idle|simulating|simulated|writing|done|error + const [message, setMessage] = useState(''); + // Once a metadata transaction is sent, never offer a second one from this + // page: a retry would add a duplicate metadata entry to the organization. + const [submittedHash, setSubmittedHash] = useState(null); + // The proposal as created on chain: close time and question come from here, + // not from the (editable, refresh-reset) form. + const [binding, setBinding] = useState(null); + + useEffect(() => { + let live = true; + if (!publicClient || !proposalAddress) return undefined; + setBinding(null); + (async () => { + const readProposal = (functionName) => publicClient.readContract({ + address: proposalAddress, abi: PROPOSAL_BINDING_ABI, functionName, + }); + try { + const [marketName, collateralToken1, collateralToken2, questionId, realityProxy] = await Promise.all([ + readProposal('marketName'), readProposal('collateralToken1'), readProposal('collateralToken2'), + readProposal('questionId'), readProposal('realityProxy'), + ]); + const realitio = await publicClient.readContract({ + address: realityProxy, abi: REALITY_PROXY_ABI, functionName: 'realitio', + }); + const openingTs = await publicClient.readContract({ + address: realitio, abi: REALITIO_ABI, functionName: 'getOpeningTS', args: [questionId], + }); + if (live) { + setBinding(bindProposalToOrganization({ + openingTs, collateralToken1, collateralToken2, marketName, organization, + openingBufferSeconds: REALITY_OPENING_BUFFER_SECONDS, + })); + } + } catch (e) { + if (live) setBinding({ ok: false, errors: [`Could not read the proposal from chain: ${e.shortMessage || e.message}`] }); + } + })(); + return () => { live = false; }; + }, [publicClient, proposalAddress, organization]); + + useEffect(() => { + let live = true; + if (!publicClient) return undefined; + (async () => { + const read = (fn) => publicClient.readContract({ + address: organization.organizationAddress, + abi: CONTRACT_ABIS.ORGANIZATION, + functionName: fn, + }).catch(() => null); + const [owner, editor] = await Promise.all([read('owner'), read('editor')]); + if (live) setRoles({ owner, editor }); + })(); + return () => { live = false; }; + }, [publicClient, organization.organizationAddress]); + + const canWrite = Boolean(account && roles + && [roles.owner, roles.editor].some((a) => a && a.toLowerCase() === account.toLowerCase())); + + const ensureGnosis = () => ensureWalletChain({ + currentChainId, + targetChainId: GNOSIS_CHAIN_ID, + switchChainAsync, + readWalletChainId: readInjectedWalletChainId, + }); + + // Recomputed at click time — never from a page-load-stale draft. The + // validator's past-start gate then only fires when the CLOSE DATE itself is + // too near (needs >= 7 days out for the 5d window + 48h buffer). + const buildDraft = () => { + if (!binding?.ok) { + return { draft: null, validation: { ok: false, errors: binding?.errors || ['Still reading the proposal from chain.'] } }; + } + const bound = { ...form, closeTimestamp: binding.closeTimestamp, question: binding.question }; + const draft = { + ...buildMetadataDraft({ ...bound, organizationId, proposalAddress }), + ...deriveTwapTiming(binding.closeTimestamp), + invertTwapPoolYes: false, // verified and corrected against real token0 in step 3 + invertTwapPoolNo: false, + }; + return { draft, validation: validateMetadata(draft, { nowUnix: Math.floor(Date.now() / 1000) }) }; + }; + + const writeRequest = (draft) => ({ + address: organization.organizationAddress, + abi: CONTRACT_ABIS.ORGANIZATION, + functionName: 'createAndAddProposalMetadata', + args: buildProposalMetadataArgs({ + proposalAddress, + question: binding.question, + event: form.displayTitle1, + description: form.description, + metadata: draft, + }), + chainId: GNOSIS_CHAIN_ID, + account, + }); + + const onSimulate = async () => { + const { draft, validation } = buildDraft(); + if (!validation.ok) { + setPhase('error'); + setMessage(`Metadata invalid: ${validation.errors.join('; ')}`); + return; + } + setPhase('simulating'); + try { + await ensureGnosis(); + await publicClient.simulateContract(writeRequest(draft)); + setPhase('simulated'); + setMessage('Simulation OK — the org write would succeed from this wallet.'); + } catch (e) { + setPhase('error'); + setMessage(`Would revert: ${e.shortMessage || e.message}`); + } + }; + + const onBroadcast = async () => { + if (submittedHash) return; + const { draft, validation } = buildDraft(); + if (!validation.ok) { + setPhase('error'); + setMessage(`Metadata invalid: ${validation.errors.join('; ')}`); + return; + } + setPhase('writing'); + try { + await ensureGnosis(); + const hash = await writeContractAsync(writeRequest(draft)); + setSubmittedHash(hash); + setMessage(`Submitted ${hash} — waiting for confirmation…`); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const created = findCreatedMetadataAddress( + receipt.logs, + organization.organizationAddress, + proposalAddress, + ); + if (!created) { + throw new Error('Transaction confirmed, but no matching ProposalCreatedAndAdded event was found.'); + } + setPhase('done'); + setMessage(`Metadata written — contract ${created}.`); + onMetadataContract(created); + } catch (e) { + setPhase('error'); + setMessage(e.shortMessage || e.message); + } + }; + + return ( +
+

Step 2 — Write proposal metadata

+

+ Registers the market's metadata (TWAP window recomputed now, Snapshot id, ticker) on the + organization contract. Requires the org owner or editor key. +

+ {binding && !binding.ok && ( + Cannot write metadata for this proposal: {binding.errors.join(' ')} + )} + {binding?.ok && binding.closeTimestamp !== form.closeTimestamp && ( + + Using the proposal's on-chain close time ({new Date(binding.closeTimestamp * 1000).toUTCString()}), + not the date in the form. + + )} + {submittedHash && phase === 'error' && ( + A metadata transaction ({submittedHash}) was already sent. Check it on the explorer before trying again from a fresh page. + )} + {roles && !canWrite && ( + + This wallet is not the org owner{roles.owner ? ` (${roles.owner})` : ''} or editor + {roles.editor ? ` (${roles.editor})` : ''} — ask the org owner to grant editor(). + + )} + {metadataAddress ? ( + Done — metadata contract {metadataAddress} + ) : ( +
+ + +
+ )} + {message && {message}} +
+ ); +} + +// --------------------------------------------------------------------------- +// Step 3 — pools + invert verification +// --------------------------------------------------------------------------- +function PoolCreator({ label, token0, token1, initialPrice, existingPool, onCreated }) { + const { createPool, status, poolAddress, isCreating } = useCreatePool(); + const publishedAddress = useRef(null); + useEffect(() => { + const nextAddress = nextPoolPublication(poolAddress, publishedAddress.current); + if (!nextAddress) return; + publishedAddress.current = nextAddress; + onCreated(nextAddress); + }, [poolAddress, onCreated]); + + if (existingPool) { + return {label} pool exists — {existingPool}; + } + return ( +
+ + {status?.message && {status.message}} +
+ ); +} + +export function PoolsPanel({ proposalAddress, organization, metadataAddress }) { + const { address: account, isConnected } = useAccount(); + const currentChainId = useChainId(); + const { switchChainAsync } = useSwitchChain(); + const publicClient = usePublicClient({ chainId: GNOSIS_CHAIN_ID }); + const { writeContractAsync } = useWriteContract(); + const [chainInfo, setChainInfo] = useState(null); // fetchProposalFromChain result + const [initialPrice, setInitialPrice] = useState(''); + const [pools, setPools] = useState({ yes: null, no: null }); + const [verify, setVerify] = useState(null); // { ok, patch, merged, message } | null + const [fixing, setFixing] = useState(false); + + const ensureGnosis = () => ensureWalletChain({ + currentChainId, + targetChainId: GNOSIS_CHAIN_ID, + switchChainAsync, + readWalletChainId: readInjectedWalletChainId, + }); + + useEffect(() => { + let live = true; + (async () => { + const info = await fetchProposalFromChain(proposalAddress, GNOSIS_CHAIN_ID); + if (!live || !info) return; + setChainInfo(info); + const yes = info.pools.find((p) => p.outcomeSide === 'YES')?.id || null; + const no = info.pools.find((p) => p.outcomeSide === 'NO')?.id || null; + setPools({ yes, no }); + })(); + return () => { live = false; }; + }, [proposalAddress]); + + // Prefill the initial price from the org's spot ticker (last candle close). + // Editable: a wrong initial price is the seeded-1000x-off bug — the operator + // must eyeball it against the real spot price before creating pools. + useEffect(() => { + let live = true; + (async () => { + try { + const { default: fetchSpotCandles } = await import('../../../spotPriceUtils/spotClient'); + const candles = await fetchSpotCandles(organization.spotPrice.ticker); + const last = candles?.[candles.length - 1]; // spotClient returns {time, value} + if (live && last?.value > 0) setInitialPrice(String(last.value)); + } catch { /* leave blank — operator fills in */ } + })(); + return () => { live = false; }; + }, [organization.spotPrice.ticker]); + + const tokens = useMemo(() => { + if (!chainInfo) return null; + const bySymbolPrefix = (prefix, base) => + chainInfo.outcomeTokens.find((t) => t.symbol?.toUpperCase().startsWith(`${prefix}_${base.toUpperCase()}`))?.id; + return { + yesCompany: chainInfo.outcomeTokens[0]?.id, + noCompany: chainInfo.outcomeTokens[1]?.id, + yesCurrency: chainInfo.outcomeTokens[2]?.id, + noCurrency: chainInfo.outcomeTokens[3]?.id, + // symbol-based cross-check; wrappedOutcome index order is the authority + _check: bySymbolPrefix('YES', chainInfo.companyToken.symbol), + }; + }, [chainInfo]); + + const runVerification = async () => { + setVerify(null); + try { + const [yesToken0, noToken0, rawMetadata, metadataURI] = await Promise.all([ + publicClient.readContract({ address: pools.yes, abi: TOKEN0_ABI, functionName: 'token0' }), + publicClient.readContract({ address: pools.no, abi: TOKEN0_ABI, functionName: 'token0' }), + publicClient.readContract({ address: metadataAddress, abi: CONTRACT_ABIS.PROPOSAL, functionName: 'metadata' }), + publicClient.readContract({ address: metadataAddress, abi: CONTRACT_ABIS.PROPOSAL, functionName: 'metadataURI' }).catch(() => ''), + ]); + const original = JSON.parse(rawMetadata || '{}'); + const ctx = buildInvertContext({ + yesCompanyToken: tokens.yesCompany, + noCompanyToken: tokens.noCompany, + yesPoolToken0: yesToken0, + noPoolToken0: noToken0, + }); + const result = validateMetadata(original, ctx); + const patch = buildInvertPatch(original, result.corrected); + if (!patch) { + setVerify({ ok: true, message: 'Invert flags verified against on-chain token0 — correct.' }); + } else { + const merged = mergeMetadataForUpdate(rawMetadata, patch); + setVerify({ + ok: false, + patch, + merged, + metadataURI, + message: `Flags wrong on-chain (${Object.entries(patch).map(([k, v]) => `${k}→${v}`).join(', ')}) — one transaction fixes them.`, + }); + } + } catch (e) { + setVerify({ ok: false, message: `Verification failed: ${e.shortMessage || e.message}` }); + } + }; + + const fixFlags = async () => { + setFixing(true); + try { + await ensureGnosis(); + const hash = await writeContractAsync({ + address: metadataAddress, + abi: CONTRACT_ABIS.PROPOSAL, + functionName: 'updateExtendedMetadata', + args: [JSON.stringify(verify.merged), verify.metadataURI || ''], + chainId: GNOSIS_CHAIN_ID, + account, + }); + await publicClient.waitForTransactionReceipt({ hash }); + await runVerification(); + } catch (e) { + setVerify((v) => ({ ...v, message: e.shortMessage || e.message })); + } finally { + setFixing(false); + } + }; + + const bothPools = Boolean(pools.yes && pools.no); + const onYesPoolCreated = useCallback( + (addr) => setPools((p) => p.yes === addr ? p : ({ ...p, yes: addr })), + [], + ); + const onNoPoolCreated = useCallback( + (addr) => setPools((p) => p.no === addr ? p : ({ ...p, no: addr })), + [], + ); + + return ( +
+

Step 3 — Conditional pools + invert verification

+ {!chainInfo ? ( + Reading conditional tokens from the proposal… + ) : ( + <> +
+ + setInitialPrice(e.target.value)} + placeholder="spot price" + /> +

+ Must match the real spot price — a wrong initial price permanently distorts quotes. +

+
+ + + +
+ + {!metadataAddress && Write metadata (step 2) first.} + {verify && ( + {verify.message} + )} + {verify && verify.patch && ( + + )} +

+ After seeding liquidity, make one small trigger trade so charts and pool liquidity + read correctly from the first candle. +

+
+ + )} +
+ ); +} + +export default function WizardSteps23({ proposalAddress, form, organization, organizationId }) { + const [metadataAddress, setMetadataAddress] = useState(null); + if (!ADDR.test(proposalAddress || '')) return null; + return ( +
+ + +
+ ); +} diff --git a/src/features/marketCreation/chainGuard.js b/src/features/marketCreation/chainGuard.js new file mode 100644 index 00000000..be98e5dc --- /dev/null +++ b/src/features/marketCreation/chainGuard.js @@ -0,0 +1,29 @@ +/** + * Switch to the target wallet chain and verify the provider did switch before + * a wallet-backed write. `readWalletChainId` is optional for connectors that + * do not expose an injected provider; wagmi's current chain check remains the + * first gate in that case. + */ +export async function ensureWalletChain({ + currentChainId, + targetChainId, + switchChainAsync, + readWalletChainId, +}) { + if (currentChainId !== targetChainId) { + await switchChainAsync({ chainId: targetChainId }); + } + + if (readWalletChainId) { + const actualChainId = await readWalletChainId(); + if (actualChainId != null && actualChainId !== targetChainId) { + throw new Error(`Chain mismatch: expected ${targetChainId}, got ${actualChainId}`); + } + } +} + +export async function readInjectedWalletChainId() { + if (typeof window === 'undefined' || !window.ethereum?.request) return null; + const chainId = await window.ethereum.request({ method: 'eth_chainId' }); + return Number(chainId); +} diff --git a/src/features/marketCreation/orgMetadataWrite.js b/src/features/marketCreation/orgMetadataWrite.js new file mode 100644 index 00000000..379172d6 --- /dev/null +++ b/src/features/marketCreation/orgMetadataWrite.js @@ -0,0 +1,84 @@ +// Pure helpers for wizard steps 2–3: the org metadata write and the +// post-pool invert-flag verification. Kept free of wagmi/ethers imports so +// node:test covers them directly. + +/** + * Args for Organization.createAndAddProposalMetadata — the exact tuple + * OrganizationManagerModal broadcasts, shared so the wizard and the modal + * can never drift. + */ +export function buildProposalMetadataArgs({ proposalAddress, question, event, description, metadata }) { + return [proposalAddress, question, event, description, JSON.stringify(metadata), '']; +} + +/** + * validateMetadata ctx from on-chain reads: each pool's token0 paired with + * that pool's CONDITIONAL company token (wrappedOutcome 0=YES_COMPANY, + * 1=NO_COMPANY — the market page's authoritative invert rule). + */ +export function buildInvertContext({ yesCompanyToken, noCompanyToken, yesPoolToken0, noPoolToken0 }) { + const pools = {}; + if (yesPoolToken0 && yesCompanyToken) { + pools.yes = { token0: yesPoolToken0, conditionalCompanyToken: yesCompanyToken }; + } + if (noPoolToken0 && noCompanyToken) { + pools.no = { token0: noPoolToken0, conditionalCompanyToken: noCompanyToken }; + } + return { pools }; +} + +/** + * Flags-only patch from a validateMetadata `corrected` result: returns just + * the invert flags that actually changed, or null when nothing did — the + * corrective updateExtendedMetadata must touch nothing else. + */ +export function buildInvertPatch(original, corrected) { + if (!corrected) return null; + const patch = {}; + for (const key of ['invertTwapPoolYes', 'invertTwapPoolNo']) { + if (Boolean(original?.[key]) !== Boolean(corrected[key])) { + patch[key] = Boolean(corrected[key]); + } + } + return Object.keys(patch).length ? patch : null; +} + +/** + * Step 2 must describe the proposal that step 1 actually created, not whatever + * the form holds now (after a refresh the form resets to defaults, and the + * close date and Snapshot fields stay editable). Takes the proposal's on-chain + * reads and returns the close time and question to write, or errors when the + * proposal does not belong to the selected organization's token pair. + * closeTimestamp = Reality opening time - the opening buffer step 1 added. + */ +export function bindProposalToOrganization({ + openingTs, + collateralToken1, + collateralToken2, + marketName, + organization, + openingBufferSeconds, +}) { + const same = (a, b) => Boolean(a && b && a.toLowerCase() === b.toLowerCase()); + const errors = []; + const opening = Number(openingTs); + if (!Number.isFinite(opening) || opening <= 0) { + errors.push('Could not read the proposal opening time from Reality.eth.'); + } + if (!same(collateralToken1, organization?.companyToken?.address)) { + errors.push(`The proposal's company token (${collateralToken1 || 'unreadable'}) is not ${organization?.name || 'this organization'}'s.`); + } + if (!same(collateralToken2, organization?.currencyToken?.address)) { + errors.push(`The proposal's currency token (${collateralToken2 || 'unreadable'}) is not ${organization?.name || 'this organization'}'s.`); + } + if (typeof marketName !== 'string' || !marketName.trim()) { + errors.push('Could not read the proposal question.'); + } + if (errors.length) return { ok: false, errors }; + return { + ok: true, + errors: [], + closeTimestamp: opening - openingBufferSeconds, + question: marketName, + }; +} diff --git a/src/features/marketCreation/poolPublication.js b/src/features/marketCreation/poolPublication.js new file mode 100644 index 00000000..859897d1 --- /dev/null +++ b/src/features/marketCreation/poolPublication.js @@ -0,0 +1,8 @@ +/** + * Return a newly created pool address only once per PoolCreator instance. + * A null result means there is nothing new to publish to the parent state. + */ +export function nextPoolPublication(poolAddress, publishedAddress) { + if (!poolAddress || poolAddress === publishedAddress) return null; + return poolAddress; +} diff --git a/src/features/marketCreation/snapshotTiming.js b/src/features/marketCreation/snapshotTiming.js new file mode 100644 index 00000000..7e293df4 --- /dev/null +++ b/src/features/marketCreation/snapshotTiming.js @@ -0,0 +1,35 @@ +// Increment D: TWAP window computed from the real Snapshot vote timing. +// The vote's `end` becomes closeTimestamp; deriveTwapTiming then anchors the +// 0xAlex window to it and openingTime lands 48h after close — no hand-typed +// dates, so the manually-misaligned windows that shipped twice can't recur. + +export const SNAPSHOT_GRAPHQL_URL = 'https://hub.snapshot.org/graphql'; +const SNAPSHOT_ID_RE = /^0x[a-fA-F0-9]{64}$/; + +/** + * Fetch a Snapshot proposal's vote-end time. + * @param {string} snapshotId proposal hash (0x… 32 bytes) + * @param {typeof fetch} [fetchImpl] injectable for tests + * @returns {Promise<{end:number, state:string}|null>} epoch seconds, or null + * when the id is malformed, unknown, or the hub is unreachable. + */ +export async function fetchSnapshotVoteEnd(snapshotId, fetchImpl = fetch) { + if (!SNAPSHOT_ID_RE.test(snapshotId || '')) return null; + try { + const response = await fetchImpl(SNAPSHOT_GRAPHQL_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'query ($id: String!) { proposal(id: $id) { end state } }', + variables: { id: snapshotId }, + }), + }); + if (!response.ok) return null; + const { data } = await response.json(); + const proposal = data?.proposal; + if (!proposal || !Number.isFinite(Number(proposal.end))) return null; + return { end: Number(proposal.end), state: proposal.state || 'unknown' }; + } catch { + return null; + } +} diff --git a/test/e2e-fork-demo.mjs b/test/e2e-fork-demo.mjs new file mode 100644 index 00000000..6a506afe --- /dev/null +++ b/test/e2e-fork-demo.mjs @@ -0,0 +1,356 @@ +// End-to-end demonstration of the self-serve client journey on a Gnosis fork: +// create proposal → org metadata write → conditional pools → invert-flag +// verification (and correction) — the exact call paths the wizard UI uses, +// executed by a NON-KELVIN client wallet. Zero transactions from Kelvin's +// proposer EOA (0x645A…); the org owner appears once only to grant editor() +// to the client (the ratified mixed-mode onboarding step). +// +// Run: node test/e2e-fork-demo.mjs (spawns its own anvil fork) +// ANVIL_RPC=http://127.0.0.1:8546 node test/e2e-fork-demo.mjs (reuse one) +// +// This is the goal's success-criterion mechanics on a fork; the browser-level +// twin of this flow is the auto-qa harness scenario that follows PR #94. +import { spawn } from 'node:child_process'; +import assert from 'node:assert/strict'; +import { ethers } from 'ethers'; +import { + createMarketWizardDefaults, + buildMetadataDraft, + deriveTwapTiming, + REALITY_OPENING_BUFFER_SECONDS, + TWAP_BUFFER_SECONDS, +} from '../src/features/marketCreation/marketCreationWorkflow.js'; +import { buildProposalParams, FUTARCHY_FACTORY_ABI } from '../src/features/marketCreation/proposalCalldata.js'; +import { buildProposalMetadataArgs, buildInvertContext, buildInvertPatch } from '../src/features/marketCreation/orgMetadataWrite.js'; +import { validateMetadata, mergeMetadataForUpdate } from '../src/features/marketCreation/validateMetadata.js'; + +const FORK_URL = process.env.FORK_URL || 'https://rpc.gnosischain.com'; +const KELVIN_EOA = '0x645a0b04e0891eb8b4a1a15dd547e3e29b17c157'; // must sign NOTHING +const ORG = '0xaAB097ead5c2Db1Ca7b1E5034224A2118EDAbe36'; // Kleros DAO org +const ORG_OWNER = '0xeb2aec308e7b3340dea2e89d40187d2637c6c649'; +const FACTORY = '0xa6cB18FCDC17a2B44E5cAd2d80a6D5942d30a345'; +const NFPM = '0x91fd594c46d8b01e62dbdebed2401dde01817834'; // Algebra position manager +const ALGEBRA_FACTORY = '0xA0864cCA6E114013AB0e27cbd5B6f4c8947da766'; + +const FULL = process.argv.includes('--full') || process.env.FULL === '1'; +const ROUTER = '0x7495a583ba85875d59407781b4958ED6e0E1228f'; // FutarchyRouter (adapter) +const ROUTER_ABI = [ + 'function splitPosition(address proposal, address collateralToken, uint256 amount)', + 'function redeemProposal(address proposal, uint256 amount1, uint256 amount2)', +]; +const ERC20_ABI = [ + 'function approve(address,uint256) returns (bool)', + 'function balanceOf(address) view returns (uint256)', + 'function transfer(address,uint256) returns (bool)', + 'function decimals() view returns (uint8)', +]; +const FACTORY_RESOLUTION_ABI = [ + 'function realityProxy() view returns (address)', + 'function realitio() view returns (address)', + 'function conditionalTokens() view returns (address)', +]; +const REALITY_PROXY_ABI = ['function resolve(address proposal)']; +const CTF_ABI = ['function payoutNumerators(bytes32, uint256) view returns (uint256)']; +const REALITY_ABI = [ + 'function submitAnswer(bytes32 question_id, bytes32 answer, uint256 max_previous) payable', + 'function resultFor(bytes32) view returns (bytes32)', + 'function isFinalized(bytes32) view returns (bool)', +]; +const NFPM_MINT_ABI = [ + 'function mint((address token0, address token1, int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, address recipient, uint256 deadline)) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)', +]; + +const ORG_ABI = [ + 'function setEditor(address _editor)', + 'function editor() view returns (address)', + 'function owner() view returns (address)', + 'function createAndAddProposalMetadata(address,string,string,string,string,string)', + 'function getProposals(uint256,uint256) view returns (address[])', +]; +const PROPOSAL_ABI = [ + 'function wrappedOutcome(uint256) view returns (address, bytes)', +]; +const METADATA_ABI = [ + 'function metadata() view returns (string)', + 'function metadataURI() view returns (string)', + 'function updateExtendedMetadata(string,string)', + 'function editor() view returns (address)', + 'function owner() view returns (address)', +]; +const NFPM_ABI = [ + 'function createAndInitializePoolIfNecessary(address token0, address token1, uint160 sqrtPriceX96) payable returns (address pool)', +]; +const ALGEBRA_FACTORY_ABI = ['function poolByPair(address,address) view returns (address)']; +const POOL_ABI = ['function token0() view returns (address)']; + +// Same math as useCreatePool (not importable in node: that file pulls wagmi). +function priceToSqrtPriceX96(price) { + return BigInt(Math.floor(Math.sqrt(price) * Number(2n ** 96n))); +} +function ammOrder(a, b, price) { + return a.toLowerCase() < b.toLowerCase() + ? { token0: a, token1: b, sqrtPrice: priceToSqrtPriceX96(price) } + : { token0: b, token1: a, sqrtPrice: priceToSqrtPriceX96(1 / price) }; +} + +async function startAnvil() { + if (process.env.ANVIL_RPC) return { url: process.env.ANVIL_RPC, proc: null }; + const proc = spawn('anvil', ['--fork-url', FORK_URL, '--port', '8547', '--silent'], { stdio: 'ignore' }); + const url = 'http://127.0.0.1:8547'; + const probe = new ethers.providers.JsonRpcProvider(url); + for (let i = 0; i < 60; i++) { + try { await probe.getBlockNumber(); return { url, proc }; } + catch { await new Promise((r) => setTimeout(r, 1000)); } + } + throw new Error('anvil did not become ready'); +} + +const { url, proc } = await startAnvil(); +const provider = new ethers.providers.JsonRpcProvider(url); +const signedBy = []; +const track = async (txPromise, label) => { + const tx = await txPromise; + const receipt = await tx.wait(); + signedBy.push({ label, from: receipt.from.toLowerCase() }); + console.log(` ✓ ${label} (from ${receipt.from}, gas ${receipt.gasUsed})`); + return receipt; +}; + +try { + // The non-Kelvin client: anvil's default funded account #0. + const client = provider.getSigner(0); + const clientAddr = await client.getAddress(); + console.log(`client org-manager wallet: ${clientAddr}`); + + // ── One-time org onboarding (mixed-mode): owner grants editor() to client ── + await provider.send('anvil_impersonateAccount', [ORG_OWNER]); + await provider.send('anvil_setBalance', [ORG_OWNER, '0x8AC7230489E80000']); + const orgAsOwner = new ethers.Contract(ORG, ORG_ABI, provider.getSigner(ORG_OWNER)); + await track(orgAsOwner.setEditor(clientAddr), 'org.setEditor(client) — one-time onboarding grant'); + await provider.send('anvil_stopImpersonatingAccount', [ORG_OWNER]); + + // ── Step 1: createProposal, wizard defaults + shared calldata builder ── + const now = (await provider.getBlock('latest')).timestamp; + const form = createMarketWizardDefaults({ organizationId: 'kleros', nowSeconds: now }); + const formData = { + chainId: 100, + marketName: `${form.proposalCode}-E2E-DEMO`, + companyToken: form.companyToken.address, + currencyToken: form.currencyToken.address, + category: 'crypto', + language: 'en', + minBond: form.minBondWei, + openingTimeUnix: form.closeTimestamp + REALITY_OPENING_BUFFER_SECONDS, + }; + const params = buildProposalParams(formData); + const factory = new ethers.Contract(FACTORY, FUTARCHY_FACTORY_ABI, client); + const proposalAddress = await factory.callStatic.createProposal(params); + await track(factory.createProposal(params), `factory.createProposal → ${proposalAddress}`); + + // ── Step 2: metadata write by the client (as org editor) ── + const timing = deriveTwapTiming(form.closeTimestamp); + const draft = { + ...buildMetadataDraft({ ...form, organizationId: 'kleros', proposalAddress, nowSeconds: now }), + ...timing, + invertTwapPoolYes: false, + invertTwapPoolNo: false, + }; + const validation = validateMetadata(draft, { nowUnix: now }); + assert.ok(validation.errors.every((e) => !e.includes('starts in the past')), 'fresh draft must pass the past-start gate'); + const orgAsClient = new ethers.Contract(ORG, ORG_ABI, client); + await track( + orgAsClient.createAndAddProposalMetadata(...buildProposalMetadataArgs({ + proposalAddress, question: form.question, event: form.displayTitle1, + description: form.description, metadata: draft, + })), + 'org.createAndAddProposalMetadata (client as editor)' + ); + const proposals = await orgAsClient.getProposals(0, 1000); + const metadataAddress = proposals[proposals.length - 1]; + console.log(` metadata contract: ${metadataAddress}`); + + // ── Step 3: conditional pools + invert verification ── + const proposal = new ethers.Contract(proposalAddress, PROPOSAL_ABI, provider); + const [yesCompany] = await proposal.wrappedOutcome(0); + const [noCompany] = await proposal.wrappedOutcome(1); + const [yesCurrency] = await proposal.wrappedOutcome(2); + const [noCurrency] = await proposal.wrappedOutcome(3); + + const spotPrice = 0.02; // demo anchor (sDAI per PNK); the UI prefills real spot + const nfpm = new ethers.Contract(NFPM, NFPM_ABI, client); + for (const [label, a, b] of [['YES', yesCompany, yesCurrency], ['NO', noCompany, noCurrency]]) { + const o = ammOrder(a, b, spotPrice); + await track( + nfpm.createAndInitializePoolIfNecessary(o.token0, o.token1, o.sqrtPrice, { gasLimit: 16000000 }), + `create ${label} pool` + ); + } + const algebraFactory = new ethers.Contract(ALGEBRA_FACTORY, ALGEBRA_FACTORY_ABI, provider); + const yesPool = await algebraFactory.poolByPair(yesCompany, yesCurrency); + const noPool = await algebraFactory.poolByPair(noCompany, noCurrency); + const yesToken0 = await new ethers.Contract(yesPool, POOL_ABI, provider).token0(); + const noToken0 = await new ethers.Contract(noPool, POOL_ABI, provider).token0(); + + const metadataContract = new ethers.Contract(metadataAddress, METADATA_ABI, provider); + const raw = await metadataContract.metadata(); + const onChain = JSON.parse(raw); + const ctx = buildInvertContext({ + yesCompanyToken: yesCompany, noCompanyToken: noCompany, + yesPoolToken0: yesToken0, noPoolToken0: noToken0, + }); + let result = validateMetadata(onChain, ctx); + const patch = buildInvertPatch(onChain, result.corrected); + if (patch) { + console.log(` invert flags wrong on-chain (${JSON.stringify(patch)}) — client fixes with one tx`); + const merged = mergeMetadataForUpdate(raw, patch); + const uri = await metadataContract.metadataURI().catch(() => ''); + await track( + metadataContract.connect(client).updateExtendedMetadata(JSON.stringify(merged), uri), + 'metadata.updateExtendedMetadata (flags-only correction)' + ); + result = validateMetadata(mergeMetadataForUpdate(await metadataContract.metadata(), {}), ctx); + } + + // ── Assertions: the goal's success-criterion mechanics ── + assert.equal(result.errors.filter((e) => e.includes('invert')).length, 0, 'invert flags verified against on-chain token0'); + assert.equal(Number(onChain.twapDurationHours), 120, '0xAlex window: 120h'); + assert.equal(Number(onChain.twapStartTimestamp) + 120 * 3600, Number(onChain.closeTimestamp) - TWAP_BUFFER_SECONDS, 'window ends 48h before close'); + assert.ok(Number(onChain.twapStartTimestamp) >= now - 60, 'window starts at creation, not before'); + const kelvinTxs = signedBy.filter((t) => t.from === KELVIN_EOA); + assert.equal(kelvinTxs.length, 0, 'zero Kelvin-signed transactions'); + const ownerTxs = signedBy.filter((t) => t.from === ORG_OWNER.toLowerCase()); + assert.equal(ownerTxs.length, 1, 'org owner appears exactly once (the onboarding grant)'); + + console.log('\nPASS (create → metadata → pools → invert-verified):'); + console.log(` proposal ${proposalAddress}`); + console.log(` metadata ${metadataAddress} (TWAP ${onChain.twapStartTimestamp} +120h, ends close−48h)`); + console.log(` pools YES ${yesPool} / NO ${noPool}, invert flags verified`); + console.log(` ${signedBy.length} txs total, 0 from Kelvin's EOA, owner only for setEditor`); + + if (FULL) { + const { evaluateFloor } = await import('../src/features/marketCreation/liquidityFloor.js'); + const wei = (n) => ethers.utils.parseEther(String(n)); + + // ── LIVE: fund the client from fork whales, split, seed both pools ── + // Demo amounts only — the PRODUCT's liquidity defaults stay a deferred + // Kelvin decision (§5a); a throwaway fork demo decides nothing. + const fundFromWhale = async (token, amount, label) => { + const holders = await (await fetch(`https://gnosis.blockscout.com/api/v2/tokens/${token}/holders`)).json(); + const erc20 = new ethers.Contract(token, ERC20_ABI, provider); + for (const h of holders.items || []) { + const whale = h.address?.hash || h.address; + if (!whale || whale.toLowerCase() === ROUTER.toLowerCase()) continue; + try { + if ((await erc20.balanceOf(whale)).lt(amount)) continue; + await provider.send('anvil_impersonateAccount', [whale]); + await provider.send('anvil_setBalance', [whale, '0x8AC7230489E80000']); + const ok = await erc20.connect(provider.getSigner(whale)).transfer(clientAddr, amount) + .then((tx) => tx.wait()).then(() => true).catch(() => false); + await provider.send('anvil_stopImpersonatingAccount', [whale]); + if (ok) { console.log(` funded ${label} from whale ${whale}`); return true; } + } catch { /* try next holder */ } + } + throw new Error(`no usable ${label} whale on fork`); + }; + const COMPANY = form.companyToken.address; // PNK + const CURRENCY = form.currencyToken.address; // sDAI + const currencyAmount = wei(12000); // ~$12k → floor comfortably passes + const companyAmount = wei(Math.round(12000 / spotPrice)); // matching value at spot + await fundFromWhale(CURRENCY, currencyAmount, 'sDAI'); + await fundFromWhale(COMPANY, companyAmount, 'PNK'); + + const router = new ethers.Contract(ROUTER, ROUTER_ABI, client); + for (const [token, amount, label] of [[CURRENCY, currencyAmount, 'sDAI'], [COMPANY, companyAmount, 'PNK']]) { + await track(new ethers.Contract(token, ERC20_ABI, client).approve(ROUTER, amount), `approve router (${label})`); + await track(router.splitPosition(proposalAddress, token, amount, { gasLimit: 3000000 }), `router.splitPosition ${label} → YES/NO`); + } + + // LP both conditional pools full-range; keep 10% of outcome tokens unpooled + // so redemption after resolution is visible in the wallet. + const nfpmMint = new ethers.Contract(NFPM, NFPM_MINT_ABI, client); + const seedPool = async (label, companyTok, currencyTok) => { + const coBal = (await new ethers.Contract(companyTok, ERC20_ABI, provider).balanceOf(clientAddr)).mul(90).div(100); + const cuBal = (await new ethers.Contract(currencyTok, ERC20_ABI, provider).balanceOf(clientAddr)).mul(90).div(100); + await track(new ethers.Contract(companyTok, ERC20_ABI, client).approve(NFPM, coBal), `approve NFPM (${label} company)`); + await track(new ethers.Contract(currencyTok, ERC20_ABI, client).approve(NFPM, cuBal), `approve NFPM (${label} currency)`); + const [t0, t1] = companyTok.toLowerCase() < currencyTok.toLowerCase() ? [companyTok, currencyTok] : [currencyTok, companyTok]; + const [a0, a1] = t0 === companyTok ? [coBal, cuBal] : [cuBal, coBal]; + await track(nfpmMint.mint({ + token0: t0, token1: t1, tickLower: -887220, tickUpper: 887220, + amount0Desired: a0, amount1Desired: a1, amount0Min: 0, amount1Min: 0, + recipient: clientAddr, deadline: now + 86400 * 30, + }, { gasLimit: 16000000 }), `seed ${label} pool liquidity`); + }; + await seedPool('YES', yesCompany, yesCurrency); + await seedPool('NO', noCompany, noCurrency); + + // Floor gate on the REAL seeded reserves → LIVE. + const yesCurrencyReserve = Number(ethers.utils.formatEther( + await new ethers.Contract(yesCurrency, ERC20_ABI, provider).balanceOf(yesPool))); + const floor = evaluateFloor({ + reserveInTokens: yesCurrencyReserve, + reserveOutTokens: yesCurrencyReserve / spotPrice, + inputUsdPrice: 1, + }); + assert.equal(floor.state, 'LIVE', `floor gate must pass with seeded reserves (${floor.reason})`); + console.log(` LIVE — YES pool holds ~$${Math.round(yesCurrencyReserve)} currency; ${floor.reason}`); + + // ── RESOLVED: warp past openingTime, answer on Reality, resolve, redeem ── + // futarchyProposalParams() struct shape varies across factory versions — + // decode raw: word0 = conditionId, word6 = questionId (verified against + // this factory's live return layout; both sanity-asserted nonzero). + const rawParams = await provider.call({ to: proposalAddress, data: '0x66b32916' }); + const word = (i) => '0x' + rawParams.slice(2 + i * 64, 2 + (i + 1) * 64); + const params2 = { conditionId: word(0), questionId: word(6) }; + assert.notEqual(params2.conditionId, ethers.constants.HashZero, 'conditionId decoded'); + assert.notEqual(params2.questionId, ethers.constants.HashZero, 'questionId decoded'); + const factoryRes = new ethers.Contract(FACTORY, FACTORY_RESOLUTION_ABI, provider); + const [realityProxyAddr, realityAddr, ctfAddr] = await Promise.all([ + factoryRes.realityProxy(), factoryRes.realitio(), factoryRes.conditionalTokens(), + ]); + const openingTime = formData.openingTimeUnix; + await provider.send('evm_setNextBlockTimestamp', [openingTime + 60]); + await provider.send('evm_mine', []); + const reality = new ethers.Contract(realityAddr, REALITY_ABI, client); + const YES_ANSWER = ethers.utils.hexZeroPad('0x01', 32); // outcome index 1 = proposal accepted + await track( + reality.submitAnswer(params2.questionId, YES_ANSWER, 0, { value: wei(1) }), + 'reality.submitAnswer (1 xDAI bond) — client-posted' + ); + await provider.send('evm_increaseTime', [Math.ceil(3.5 * 86400) + 60]); + await provider.send('evm_mine', []); + assert.equal(await reality.isFinalized(params2.questionId), true, 'Reality answer finalized after timeout'); + await track( + new ethers.Contract(realityProxyAddr, REALITY_PROXY_ABI, client).resolve(proposalAddress, { gasLimit: 2000000 }), + 'realityProxy.resolve(proposal) — permissionless' + ); + + // Which side won? Read the CTF payout vector, then redeem that side's + // unpooled outcome tokens back to collateral. + const ctf = new ethers.Contract(ctfAddr, CTF_ABI, provider); + const payout0 = await ctf.payoutNumerators(params2.conditionId, 0); + const winner = payout0.gt(0) + ? { label: 'outcome0', company: yesCompany, currency: yesCurrency } + : { label: 'outcome1', company: noCompany, currency: noCurrency }; + console.log(` resolved — winning side: ${winner.label}`); + const balOf = (t) => new ethers.Contract(t, ERC20_ABI, provider).balanceOf(clientAddr); + const approveMax = (t, label) => track( + new ethers.Contract(t, ERC20_ABI, client).approve(ROUTER, ethers.constants.MaxUint256), `approve router redeem (${label})`); + const currencyBefore = await balOf(CURRENCY); + const [winCoBal, winCuBal] = [await balOf(winner.company), await balOf(winner.currency)]; + await approveMax(winner.company, 'winning company'); + await approveMax(winner.currency, 'winning currency'); + await track(router.redeemProposal(proposalAddress, winCoBal, winCuBal, { gasLimit: 3000000 }), 'router.redeemProposal (winning side → collateral)'); + const currencyAfter = await balOf(CURRENCY); + assert.ok(currencyAfter.gt(currencyBefore), 'redemption returned collateral to the client'); + + const kelvinFullTxs = signedBy.filter((t) => t.from === KELVIN_EOA); + assert.equal(kelvinFullTxs.length, 0, 'zero Kelvin-signed transactions through LIVE → resolved'); + console.log('\nPASS FULL — create → metadata → pools → LIVE → resolved → redeemed:'); + console.log(` answer finalized on Reality ${realityAddr}, proposal resolved permissionlessly`); + console.log(` client redeemed ${ethers.utils.formatEther(currencyAfter.sub(currencyBefore))} sDAI of winning outcome tokens`); + console.log(` ${signedBy.length} txs total — 0 from Kelvin's EOA, org owner only the setEditor grant`); + } +} finally { + if (proc) proc.kill(); +} diff --git a/test/e2e-ui-demo.mjs b/test/e2e-ui-demo.mjs new file mode 100644 index 00000000..02804b8f --- /dev/null +++ b/test/e2e-ui-demo.mjs @@ -0,0 +1,232 @@ +// Browser-level demonstration: the wizard's steps 1–3 driven ENTIRELY THROUGH +// THE UI (the built static export) against an anvil fork of Gnosis. All +// production RPC endpoints are routed to the fork; a minimal EIP-1193 wallet +// exposes anvil's unlocked client account. Zero Kelvin-signed transactions; +// the org owner appears once, before the UI run, for the setEditor grant +// (mixed-mode onboarding). +// +// Prereq: `npx next build` (out/ present). Run: node test/e2e-ui-demo.mjs +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { join, extname } from 'node:path'; +import assert from 'node:assert/strict'; +import { ethers } from 'ethers'; + +const HARNESS_PW = '/Users/kas/interface/auto-qa/harness/node_modules/playwright/index.mjs'; +const FORK_URL = process.env.FORK_URL || 'https://rpc.gnosischain.com'; +const ANVIL = 'http://127.0.0.1:8548'; +const ORG = '0xaAB097ead5c2Db1Ca7b1E5034224A2118EDAbe36'; +const ORG_OWNER = '0xeb2aec308e7b3340dea2e89d40187d2637c6c649'; +const KELVIN_EOA = '0x645a0b04e0891eb8b4a1a15dd547e3e29b17c157'; + +const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' }; + +async function serveOut(root, port) { + const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://x'); + let p = decodeURIComponent(url.pathname); + if (p.endsWith('/')) p += 'index.html'; + for (const candidate of [p, `${p}.html`, `${p}/index.html`]) { + try { + const body = await readFile(join(root, candidate)); + res.writeHead(200, { 'Content-Type': MIME[extname(candidate)] || 'application/octet-stream' }); + return res.end(body); + } catch { /* next */ } + } + res.writeHead(404); res.end('not found'); + }); + await new Promise((r) => server.listen(port, r)); + return server; +} + +// ── fork + one-time onboarding grant ───────────────────────────────────── +const anvilProc = spawn('anvil', ['--fork-url', FORK_URL, '--port', '8548', '--silent'], { stdio: 'ignore' }); +const provider = new ethers.providers.JsonRpcProvider(ANVIL); +for (let i = 0; i < 60; i++) { + try { await provider.getBlockNumber(); break; } catch { await new Promise((r) => setTimeout(r, 1000)); } +} +const client = provider.getSigner(0); +const clientAddr = await client.getAddress(); +await provider.send('anvil_impersonateAccount', [ORG_OWNER]); +await provider.send('anvil_setBalance', [ORG_OWNER, '0x8AC7230489E80000']); +const grantTx = await provider.getSigner(ORG_OWNER).sendTransaction({ + to: ORG, + data: new ethers.utils.Interface(['function setEditor(address)']).encodeFunctionData('setEditor', [clientAddr]), +}); +await grantTx.wait(); +await provider.send('anvil_stopImpersonatingAccount', [ORG_OWNER]); +console.log(`fork ready — client ${clientAddr} granted editor() (org owner's only tx)`); + +const httpServer = await serveOut(new URL('../out', import.meta.url).pathname, 8549); + +// ── browser ────────────────────────────────────────────────────────────── +const { chromium } = await import(HARNESS_PW); +const browser = await chromium.launch({ + // harness playwright expects an older browser build than the cache holds — + // point straight at the cached headless shell + executablePath: '/Users/kas/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell', +}); +const context = await browser.newContext(); + +// Route EVERY known production Gnosis RPC to the fork. +const RPC_HOSTS = ['rpc.gnosischain.com', 'gnosis.drpc.org', 'rpc.ankr.com', 'gnosis-rpc.publicnode.com', '1rpc.io', 'gnosis-mainnet.public.blastapi.io', 'rpc.gnosis.gateway.fm']; +await context.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (RPC_HOSTS.some((h) => url.hostname.endsWith(h))) { + const response = await fetch(ANVIL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: route.request().postData() }); + return route.fulfill({ status: 200, contentType: 'application/json', body: await response.text() }); + } + return route.continue(); +}); + +// Minimal EIP-1193 wallet: anvil's unlocked account 0, all calls proxied. +await context.addInitScript(`(() => { + const RPC = ${JSON.stringify(ANVIL)}; + const ADDR = ${JSON.stringify(clientAddr)}; + let id = 1; + const rpc = async (method, params) => { + const r = await fetch(RPC, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: id++, method, params: params || [] }) }); + const j = await r.json(); + if (j.error) throw Object.assign(new Error(j.error.message), { code: j.error.code }); + return j.result; + }; + const listeners = {}; + const emit = (event, payload) => (listeners[event] || []).forEach((fn) => { try { fn(payload); } catch {} }); + window.ethereum = { + isMetaMask: true, + _metamask: { isUnlocked: async () => true }, + chainId: '0x64', + networkVersion: '100', + selectedAddress: ADDR, + isConnected: () => true, + enable: async () => [ADDR], + request: async ({ method, params }) => { + if (method === 'eth_requestAccounts' || method === 'eth_accounts') { + setTimeout(() => { emit('connect', { chainId: '0x64' }); emit('accountsChanged', [ADDR]); }, 0); + return [ADDR]; + } + if (method === 'eth_chainId') return '0x64'; + if (method === 'net_version') return '100'; + if (method === 'wallet_switchEthereumChain' || method === 'wallet_addEthereumChain') return null; + if (method === 'wallet_requestPermissions' || method === 'wallet_getPermissions') { + return [{ parentCapability: 'eth_accounts' }]; + } + if (method === 'eth_sendTransaction') { params[0].from = params[0].from || ADDR; return rpc(method, params); } + return rpc(method, params); + }, + on: (e, fn) => { (listeners[e] = listeners[e] || []).push(fn); }, + addListener: (e, fn) => { (listeners[e] = listeners[e] || []).push(fn); }, + removeListener: (e, fn) => { listeners[e] = (listeners[e] || []).filter((f) => f !== fn); }, + removeAllListeners: (e) => { if (e) listeners[e] = []; }, + }; + window.dispatchEvent(new Event('ethereum#initialized')); + // EIP-6963: announce as a discoverable wallet so RainbowKit/wagmi list it + // with the plain injected connector (bypasses the MetaMask SDK handshake). + const info = Object.freeze({ + uuid: '01890b5e-0000-4000-8000-e2edemowallet', + name: 'Anvil Demo Wallet', + icon: 'data:image/svg+xml;utf8,', + rdns: 'demo.anvil.fork', + }); + const announce = () => window.dispatchEvent(new CustomEvent('eip6963:announceProvider', { + detail: Object.freeze({ info, provider: window.ethereum }), + })); + window.addEventListener('eip6963:requestProvider', announce); + announce(); +})();`); + +const page = await context.newPage(); +page.setDefaultTimeout(90000); +page.on('console', (msg) => { + if (msg.type() === 'error' || /connect|wallet|wagmi|rainbow/i.test(msg.text())) { + console.log(` [console.${msg.type()}] ${msg.text().slice(0, 200)}`); + } +}); +page.on('pageerror', (e) => console.log(` [pageerror] ${String(e).slice(0, 200)}`)); +const say = (m) => console.log(` ${m}`); + +try { + await page.goto('http://127.0.0.1:8549/markets/new'); + await page.getByText('Market Defaults').first().waitFor(); + say('wizard rendered (static export, client-mounted)'); + + // Connect wallet through RainbowKit's real modal. + await page.getByRole('button', { name: /connect wallet/i }).first().click(); + const rkDialog = page.locator('[data-rk][role="dialog"]'); + await rkDialog.waitFor(); + const walletOption = rkDialog + .locator('[data-testid="rk-wallet-option-demo.anvil.fork"], [data-testid^="rk-wallet-option"]:has-text("Anvil Demo")') + .first(); + await walletOption.waitFor(); + // RainbowKit's entrance animation keeps the hit-test failing — bypass it. + await walletOption.click({ force: true, timeout: 15000 }) + .catch(() => walletOption.dispatchEvent('click')); + await page.waitForFunction(() => !document.body.innerText.match(/Connect a wallet to broadcast/i), null, { timeout: 30000 }).catch(() => {}); + say('wallet connected through RainbowKit'); + + // Step 1 — broadcast createProposal from the UI. + await page.getByRole('button', { name: /sign & broadcast/i }).click(); + await page.getByRole('button', { name: /^create proposal$/i }).click(); + const proposalLocator = page.locator('text=/proposal: 0x[a-fA-F0-9]{40}/'); + await proposalLocator.waitFor({ timeout: 120000 }); + const proposalAddress = (await proposalLocator.innerText()).match(/0x[a-fA-F0-9]{40}/)[0]; + say(`STEP 1 through UI — proposal created: ${proposalAddress}`); + + // The validator (correctly) refuses metadata without a snapshot_id — fill + // the hash. The increment-D autofill fails closed for an unknown id, so the + // manual close date stands. + await page.locator('#snapshotId').fill('0x' + 'ab'.repeat(32)); + + // Step 2 — simulate, then write metadata as the org editor. + await page.getByText('Step 2 — Write proposal metadata').waitFor(); + await page.getByRole('button', { name: /simulate write/i }).click(); + await page.getByText(/Simulation OK/i).waitFor({ timeout: 60000 }); + await page.getByRole('button', { name: /sign & write metadata/i }).click(); + await page.getByText(/Metadata written/i).waitFor({ timeout: 120000 }); + say('STEP 2 through UI — metadata written by the client as editor'); + + // Step 3 — create both pools, then verify invert flags. + await page.getByText('Step 3 — Conditional pools').waitFor(); + await page.locator('#initialPrice').fill('0.02'); + await page.getByRole('button', { name: /create YES pool/i }).click(); + await page.getByText(/YES pool exists|Pool created/i).first().waitFor({ timeout: 120000 }).catch(() => {}); + await page.getByRole('button', { name: /create NO pool/i }).click(); + await page.waitForFunction(() => { + const t = document.body.innerText; + return /YES pool exists — 0x/.test(t) && /NO pool exists — 0x/.test(t); + }, null, { timeout: 180000 }); + say('STEP 3 through UI — both conditional pools exist'); + + await page.getByRole('button', { name: /verify invert flags/i }).click(); + await page.waitForFunction(() => /verified against on-chain token0 — correct|one transaction fixes/.test(document.body.innerText), null, { timeout: 60000 }); + if (await page.getByRole('button', { name: /fix flags/i }).isVisible().catch(() => false)) { + await page.getByRole('button', { name: /fix flags/i }).click(); + await page.getByText(/verified against on-chain token0 — correct/i).waitFor({ timeout: 120000 }); + say('invert flags were wrong on-chain — the UI corrected them with one tx'); + } + say('invert flags VERIFIED through the UI'); + + // Zero Kelvin transactions: scan every fork tx since genesis of our session. + const latest = await provider.getBlockNumber(); + let kelvinTxs = 0; + for (let b = latest; b > latest - 40 && b > 0; b--) { + const block = await provider.getBlockWithTransactions(b); + kelvinTxs += block.transactions.filter((t) => t.from.toLowerCase() === KELVIN_EOA).length; + } + assert.equal(kelvinTxs, 0, 'zero Kelvin-signed transactions'); + + console.log('\nPASS UI — wizard steps 1–3 driven entirely through the real interface:'); + console.log(` proposal ${proposalAddress} · metadata written · YES+NO pools · invert verified`); + console.log(' 0 Kelvin-signed txs; org owner only the pre-UI setEditor grant'); + console.log(' (LIVE seeding + resolution mechanics: node test/e2e-fork-demo.mjs --full)'); +} catch (e) { + console.error('UI DEMO FAILED:', e.message); + await page.screenshot({ path: 'test/e2e-ui-demo-failure.png', fullPage: true }).catch(() => {}); + process.exitCode = 1; +} finally { + await browser.close(); + httpServer.close(); + anvilProc.kill(); +} diff --git a/test/marketCreation.test.mjs b/test/marketCreation.test.mjs index 2807c4c8..322227c8 100644 --- a/test/marketCreation.test.mjs +++ b/test/marketCreation.test.mjs @@ -1,11 +1,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { ethers } from 'ethers'; import { validateMetadata, mergeMetadataForUpdate } from '../src/features/marketCreation/validateMetadata.js'; import { priceImpactConstantProduct, evaluateFloor } from '../src/features/marketCreation/liquidityFloor.js'; import { deriveTwapTiming, TWAP_BUFFER_SECONDS, stepOneBroadcastErrors } from '../src/features/marketCreation/marketCreationWorkflow.js'; import { buildProposalParams } from '../src/features/marketCreation/proposalCalldata.js'; -import { findCreatedProposalAddress } from '../src/features/marketCreation/receiptEvents.js'; -import { ethers } from 'ethers'; +import { findCreatedMetadataAddress, findCreatedProposalAddress } from '../src/features/marketCreation/receiptEvents.js'; +import { ensureWalletChain } from '../src/features/marketCreation/chainGuard.js'; +import { nextPoolPublication } from '../src/features/marketCreation/poolPublication.js'; // Conditional (wrapped YES/NO) company tokens — what the pools actually hold. const COND_YES = '0x1111111111111111111111111111111111111111'; @@ -151,3 +154,168 @@ test('new proposal address comes only from the factory NewProposal event', () => assert.equal(findCreatedProposalAddress([{ address: factory, ...log }], factory), proposal); assert.equal(findCreatedProposalAddress([], factory), null); }); + +test('receipt address selection uses only matching factory/org events', () => { + const factory = '0x1000000000000000000000000000000000000001'; + const otherFactory = '0x1000000000000000000000000000000000000002'; + const proposal = '0x2000000000000000000000000000000000000001'; + const organization = '0x3000000000000000000000000000000000000001'; + const metadata = '0x4000000000000000000000000000000000000001'; + const factoryIface = new ethers.utils.Interface([ + 'event NewProposal(address indexed proposal, string marketName, bytes32 conditionId, bytes32 questionId)', + ]); + const orgIface = new ethers.utils.Interface([ + 'event ProposalCreatedAndAdded(address indexed proposalMetadata, address indexed proposalAddress)', + ]); + const factoryLog = factoryIface.encodeEventLog( + factoryIface.getEvent('NewProposal'), + [proposal, 'KIP-90', ethers.constants.HashZero, ethers.constants.HashZero], + ); + const metadataLog = orgIface.encodeEventLog( + orgIface.getEvent('ProposalCreatedAndAdded'), + [metadata, proposal], + ); + + assert.equal( + findCreatedProposalAddress([{ address: otherFactory, ...factoryLog }], factory), + null, + ); + assert.equal( + findCreatedProposalAddress([{ address: factory, ...factoryLog }], factory), + proposal, + ); + assert.equal( + findCreatedMetadataAddress([{ address: organization, ...metadataLog }], organization, proposal), + metadata, + ); + assert.equal( + findCreatedMetadataAddress([{ address: organization, ...metadataLog }], organization, otherFactory), + null, + ); +}); + +test('metadata and pool writes switch and verify the target chain', async () => { + let switchedTo = null; + await ensureWalletChain({ + currentChainId: 1, + targetChainId: 100, + switchChainAsync: async ({ chainId }) => { switchedTo = chainId; }, + readWalletChainId: async () => 100, + }); + assert.equal(switchedTo, 100); + await assert.rejects( + ensureWalletChain({ + currentChainId: 100, + targetChainId: 100, + switchChainAsync: async () => {}, + readWalletChainId: async () => 1, + }), + /Chain mismatch/, + ); +}); + +test('PoolCreator publishes each created address once and uses stable parent callbacks', () => { + const source = readFileSync(new URL('../src/components/futarchyFi/createMarket/WizardSteps23.jsx', import.meta.url), 'utf8'); + assert.match(source, /publishedAddress = useRef\(null\)/); + assert.match(source, /const onYesPoolCreated = useCallback/); + assert.match(source, /const onNoPoolCreated = useCallback/); + assert.doesNotMatch(source, /onCreated=\{\(addr\) => setPools/); + assert.equal(nextPoolPublication(null, null), null); + assert.equal(nextPoolPublication(OTHER, OTHER), null); + assert.equal(nextPoolPublication(OTHER, null), OTHER); + assert.equal(nextPoolPublication(COND_YES, OTHER), COND_YES); +}); + +test('proposal organization is persisted and locked before metadata steps', () => { + const source = readFileSync(new URL('../src/components/futarchyFi/createMarket/CreateMarketFlow.jsx', import.meta.url), 'utf8'); + assert.match(source, /organization: organizationId/); + assert.match(source, /disabled=\{Boolean\(proposalAddress\)\}/); + assert.match(source, /setProposalOrganizationId\(queryOrganization\)/); +}); + +// ---- increment C pure helpers ---- +const { buildProposalMetadataArgs, buildInvertContext, buildInvertPatch } = + await import('../src/features/marketCreation/orgMetadataWrite.js'); + +test('org metadata write args match the OrganizationManagerModal tuple', () => { + const args = buildProposalMetadataArgs({ + proposalAddress: OTHER, question: 'Q?', event: 'E', description: 'D', metadata: { a: 1 }, + }); + assert.deepEqual(args, [OTHER, 'Q?', 'E', 'D', '{"a":1}', '']); +}); + +test('invert context pairs each pool token0 with its conditional company token', () => { + const ctx = buildInvertContext({ + yesCompanyToken: COND_YES, noCompanyToken: COND_NO, + yesPoolToken0: COND_YES, noPoolToken0: OTHER, + }); + assert.deepEqual(ctx.pools.yes, { token0: COND_YES, conditionalCompanyToken: COND_YES }); + assert.deepEqual(ctx.pools.no, { token0: OTHER, conditionalCompanyToken: COND_NO }); + // partial data → that side omitted, validator defers + assert.deepEqual(buildInvertContext({ yesCompanyToken: COND_YES }).pools, {}); +}); + +test('invert patch contains ONLY changed flags; null when nothing changed', () => { + const original = { invertTwapPoolYes: false, invertTwapPoolNo: true, keep: 'x' }; + const corrected = { ...original, invertTwapPoolYes: true }; + assert.deepEqual(buildInvertPatch(original, corrected), { invertTwapPoolYes: true }); + assert.equal(buildInvertPatch(original, null), null); + assert.equal(buildInvertPatch(original, { ...original }), null); +}); + +test('end-to-end invert verification: wrong flags produce a flags-only merge', () => { + const onChain = { ...GOOD, invertTwapPoolYes: false, invertTwapPoolNo: false, precious: 'keep-me' }; + const ctx = buildInvertContext({ + yesCompanyToken: COND_YES, noCompanyToken: COND_NO, + yesPoolToken0: OTHER, noPoolToken0: COND_NO, // YES pool ordered the other way + }); + const result = validateMetadata(onChain, ctx); + const patch = buildInvertPatch(onChain, result.corrected); + assert.deepEqual(patch, { invertTwapPoolYes: true }); + const merged = mergeMetadataForUpdate(JSON.stringify(onChain), patch); + assert.equal(merged.precious, 'keep-me'); + assert.equal(merged.invertTwapPoolYes, true); + assert.equal(merged.invertTwapPoolNo, false); +}); + +// ---- increment D: Snapshot vote-timing autofill ---- +const { fetchSnapshotVoteEnd } = await import('../src/features/marketCreation/snapshotTiming.js'); +const SNAP_ID = '0x' + 'ab'.repeat(32); + +test('snapshot vote end fetched and returned as epoch seconds', async () => { + const mockFetch = async () => ({ + ok: true, + json: async () => ({ data: { proposal: { end: 1790000000, state: 'active' } } }), + }); + assert.deepEqual(await fetchSnapshotVoteEnd(SNAP_ID, mockFetch), { end: 1790000000, state: 'active' }); +}); + +test('snapshot autofill fails closed: bad id, unknown proposal, hub down', async () => { + assert.equal(await fetchSnapshotVoteEnd('not-a-hash', async () => { throw new Error('should not fetch'); }), null); + assert.equal(await fetchSnapshotVoteEnd(SNAP_ID, async () => ({ ok: true, json: async () => ({ data: { proposal: null } }) })), null); + assert.equal(await fetchSnapshotVoteEnd(SNAP_ID, async () => { throw new Error('offline'); }), null); +}); + +test('step 2 binds close time and question to the on-chain proposal', async () => { + const { bindProposalToOrganization } = await import('../src/features/marketCreation/orgMetadataWrite.js'); + const organization = { + name: 'Kleros', + companyToken: { address: '0xAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaa' }, + currencyToken: { address: '0xBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbb' }, + }; + const base = { + openingTs: 1_800_172_800, + collateralToken1: organization.companyToken.address.toLowerCase(), + collateralToken2: organization.currencyToken.address, + marketName: 'Will KIP-90 be passed by Kleros?', + organization, + openingBufferSeconds: 48 * 3600, + }; + assert.deepEqual(bindProposalToOrganization(base), { + ok: true, errors: [], closeTimestamp: 1_800_000_000, question: 'Will KIP-90 be passed by Kleros?', + }); + assert.equal(bindProposalToOrganization({ ...base, collateralToken1: OTHER }).ok, false); + assert.equal(bindProposalToOrganization({ ...base, collateralToken2: null }).ok, false); + assert.equal(bindProposalToOrganization({ ...base, openingTs: 0 }).ok, false); + assert.equal(bindProposalToOrganization({ ...base, marketName: '' }).ok, false); +});