diff --git a/app/__tests__/components/launch-backing-seed-warning.test.ts b/app/__tests__/components/launch-backing-seed-warning.test.ts new file mode 100644 index 00000000..28d09c0d --- /dev/null +++ b/app/__tests__/components/launch-backing-seed-warning.test.ts @@ -0,0 +1,77 @@ +/** + * GH#2514 — a failed backing-domain seeding must not produce a silent + * "Market created!". + * + * The sequential launch path (retry/resume with startStep <= 3, or the + * pre-broadcast fallback from fresh batching) wraps the TopUpBackingBucket + * transaction in a try/catch, warns to the console, and continues. Staying + * non-fatal is deliberate and correct — a transient RPC error must not strand + * an otherwise-live market, and a repeat TopUp against an already-Fresh-at-MAX + * bucket is a harmless no-op. + * + * Staying SILENT is the defect. That rationale was written when the seed was + * dust; it is now `backingSeedPerDomain(lp)` per domain — 100% of LP collateral + * each at the current policy — so swallowing the failure hands the creator a + * success screen for a market missing two allocations worth twice their LP. + * + * These assertions are against the source, not a rendered component: the + * property is "the catch records it and the success screen reads it", which is + * a wiring fact spanning three files. A render test of LaunchSuccess alone + * would pass even if the hook never set the flag. + */ + +import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; + +const read = (rel: string) => + fs.readFileSync(path.resolve(__dirname, "../..", rel), "utf8"); + +const HOOK = read("hooks/useCreateMarket.ts"); +const SUCCESS = read("components/create/LaunchSuccess.tsx"); +const WIZARD = read("components/create/CreateMarketWizard.tsx"); + +describe("GH#2514: backing-seed failure is recorded, not swallowed", () => { + it("the backing-bucket catch sets backingSeedFailed", () => { + // Isolate the catch block so a match elsewhere in the file cannot satisfy + // this — the flag has to be set at the failure site itself. + const start = HOOK.indexOf("} catch (backingBucketErr) {"); + expect(start).toBeGreaterThan(-1); + const block = HOOK.slice(start, start + 1400); + expect(block).toMatch(/backingSeedFailed:\s*true/); + }); + + it("keeps the step non-fatal — it must not throw or set the fatal error", () => { + // The fix is "report it", not "fail the launch". Turning this fatal would + // reintroduce exactly the stranding the original comment guards against. + const start = HOOK.indexOf("} catch (backingBucketErr) {"); + const block = HOOK.slice(start, start + 1400); + expect(block).not.toMatch(/throw\s/); + expect(block).not.toMatch(/error:\s*[`'"]/); + }); + + it("declares backingSeedFailed on the state and initialises it false", () => { + expect(HOOK).toMatch(/backingSeedFailed:\s*boolean;/); + // Every initial-state object must carry it, or the success screen reads + // undefined and silently renders nothing. + const inits = HOOK.match(/insuranceMintFailed:\s*false,/g) ?? []; + const flags = HOOK.match(/backingSeedFailed:\s*false,/g) ?? []; + expect(inits.length).toBeGreaterThan(0); + expect(flags.length).toBe(inits.length); + }); + + it("the wizard passes it to LaunchSuccess and the screen renders on it", () => { + expect(WIZARD).toMatch(/backingSeedFailed=\{createState\.backingSeedFailed\}/); + expect(SUCCESS).toMatch(/backingSeedFailed\?:\s*boolean;/); + expect(SUCCESS).toMatch(/\{backingSeedFailed\s*&&\s*\(/); + }); + + it("the warning says the market is live AND that backing is missing", () => { + // Both halves matter: dropping the first turns a soft warning into an + // apparent failure, dropping the second is the bug this closes. + const start = SUCCESS.indexOf("{backingSeedFailed && ("); + const banner = SUCCESS.slice(start, start + 900); + expect(banner).toMatch(/live and tradeable/i); + expect(banner).toMatch(/not seeded/i); + }); +}); diff --git a/app/components/create/CreateMarketWizard.tsx b/app/components/create/CreateMarketWizard.tsx index bf59780b..fedd755b 100644 --- a/app/components/create/CreateMarketWizard.tsx +++ b/app/components/create/CreateMarketWizard.tsx @@ -838,6 +838,7 @@ export const CreateMarketWizard: FC<{ initialMint?: string }> = ({ initialMint } devnetAirdropSymbol={createState.devnetAirdropSymbol} devnetMintError={createState.devnetMintError} insuranceMintFailed={createState.insuranceMintFailed} + backingSeedFailed={createState.backingSeedFailed} keeperDelegated={createState.keeperDelegated} keeperMessage={createState.keeperMessage} keeperRegistering={createState.keeperRegistering} diff --git a/app/components/create/LaunchSuccess.tsx b/app/components/create/LaunchSuccess.tsx index 7357eb21..f7406b8d 100644 --- a/app/components/create/LaunchSuccess.tsx +++ b/app/components/create/LaunchSuccess.tsx @@ -29,6 +29,8 @@ interface LaunchSuccessProps { * Shows a soft warning on the success screen; does not block trading. */ insuranceMintFailed?: boolean; + /** GH#2514: backing-domain seeding failed (non-fatal, but must not be silent). */ + backingSeedFailed?: boolean; /** Keeper oracle: true when oracle_authority was delegated to the keeper service */ keeperDelegated?: boolean; /** Keeper registration message */ @@ -63,6 +65,7 @@ export const LaunchSuccess: FC = ({ devnetAirdropSymbol, devnetMintError, insuranceMintFailed, + backingSeedFailed, keeperDelegated, keeperMessage, keeperRegistering, @@ -211,6 +214,19 @@ export const LaunchSuccess: FC = ({ + {/* GH#2514: backing-domain seeding failed. Non-fatal by design — a transient + RPC error must not strand a live market — but it must not be silent + either: at the current policy each domain's seed is 100% of LP + collateral, so an unreported failure leaves the creator believing a + market is seeded when it is short twice their LP. */} + {backingSeedFailed && ( +
+

+ Market is live and tradeable, but counterparty backing was not seeded — the deposit for both domains did not land. Retry it from market settings before the market takes size. +

+
+ )} + {/* GH#1761: Insurance LP Mint soft warning — shown when step 5 failed non-fatally */} {insuranceMintFailed && (
diff --git a/app/hooks/useCreateMarket.ts b/app/hooks/useCreateMarket.ts index 30ab9327..1c8203f4 100644 --- a/app/hooks/useCreateMarket.ts +++ b/app/hooks/useCreateMarket.ts @@ -339,6 +339,24 @@ export interface CreateMarketState { * step). Kept for backwards-compatible UI wiring; never set to true by create(). */ insuranceMintFailed: boolean; + /** + * GH#2514: set when the sequential path's backing-bucket seeding failed. + * + * That step is deliberately non-fatal (see the Step 3 comment in create()) so + * a transient RPC error cannot strand an otherwise-live market. But it was + * ALSO silent: the launch went on to report an unqualified "Market created!" + * while both backing domains were left unseeded. + * + * That was defensible when the seed was dust. It is not now the seed is + * `backingSeedPerDomain(lp)` per domain — 100% of LP collateral each at the + * current policy, so a silent failure leaves the creator's market short two + * allocations totalling twice their LP. + * + * Non-fatal is kept; silent is not. The launch still succeeds, and the + * success screen says what did not happen so the creator can retry or + * backfill rather than believing the market is fully seeded. + */ + backingSeedFailed: boolean; /** Keeper oracle mode: true when oracle_authority was delegated to keeper */ keeperDelegated: boolean; /** Keeper registration result message */ @@ -1532,6 +1550,7 @@ export function useCreateMarket() { devnetAirdropSymbol: null, devnetMintError: null, insuranceMintFailed: false, + backingSeedFailed: false, keeperDelegated: false, keeperMessage: null, keeperRegistering: false, @@ -2903,6 +2922,14 @@ export function useCreateMarket() { "deadlock until this is retried or backfilled:", backingBucketErr, ); + // GH#2514: staying non-fatal is right — a transient RPC error must + // not strand a live market, and a repeat TopUp against an + // already-Fresh-at-MAX bucket is a harmless no-op. Staying SILENT + // is not. The rationale above was written when this seed was dust; + // it is now backingSeedPerDomain(lp) per domain, so swallowing the + // failure hands the creator a "Market created!" for a market + // missing two allocations worth twice their LP collateral. + setState((s) => ({ ...s, backingSeedFailed: true })); } } @@ -3399,6 +3426,7 @@ export function useCreateMarket() { devnetAirdropSymbol: null, devnetMintError: null, insuranceMintFailed: false, + backingSeedFailed: false, keeperDelegated: false, keeperMessage: null, keeperRegistering: false,