From 8d46d949dcd38795d4d795b0a3b865a41b40000f Mon Sep 17 00:00:00 2001 From: dcccrypto Date: Fri, 14 Aug 2026 04:04:55 +0100 Subject: [PATCH] fix(create): a failed backing seed must not report unqualified success (GH#2514) The v17 sequential launch path wraps the TopUpBackingBucket transaction in a try/catch, warns to the console, and continues to "Market created!". Both backing domains can therefore be left unseeded while the creator is told the launch succeeded. Staying NON-FATAL is right, and this keeps it. The Step 3 comment explains why: a transient RPC error must not strand an otherwise-live market, and a repeat TopUp against an already-Fresh-at-MAX bucket hits the harmless no-op arm. That reasoning still holds, and making the step fatal would reintroduce exactly the stranding it guards against. Staying SILENT is the defect, and what changed underneath it is the size. That comment was written (2026-07-09) 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. A best-effort decision made about dust was left in place for something 2x the LP deposit. So: the catch now records `backingSeedFailed` on the launch state, and the success screen renders a soft warning saying the market is live and tradeable BUT that counterparty backing was not seeded and should be retried before the market takes size. Both halves of that sentence matter -- dropping the first turns a soft warning into an apparent failure, dropping the second is the bug. This follows the existing convention for exactly this situation rather than inventing one: `insuranceMintFailed` (GH#1761) is the same shape -- a non-fatal step failure carried on the state and rendered as a warning banner on the success screen -- and `devnetMintError` carries a non-fatal error the same way. Scope is the sequential path only (retry/resume with startStep <= 3, or the pre-broadcast fallback from fresh batching), which is where the catch lives. A fresh batched launch puts the LP deposit and both top-ups in one atomic M3a, so its failure is already fatal after broadcast. Tests assert the wiring across all three files, because the property is "the catch records it AND the screen reads it" -- a render test of LaunchSuccess alone would pass even if the hook never set the flag. The catch block is isolated by index so a match elsewhere in a 3,000-line file cannot satisfy it. Mutation-tested, control either side. The third round is the over-correction, which the tests reject as firmly as the original bug: control 5 passed revert the catch to silent 1 failed drop the success-screen banner 2 failed make the step fatal (over-correct) 1 failed control 5 passed Verified: 305 files, 3075 passed, 17 skipped; tsc --noEmit exit 0. Co-Authored-By: Claude Opus 5 --- .../launch-backing-seed-warning.test.ts | 77 +++++++++++++++++++ app/components/create/CreateMarketWizard.tsx | 1 + app/components/create/LaunchSuccess.tsx | 16 ++++ app/hooks/useCreateMarket.ts | 28 +++++++ 4 files changed, 122 insertions(+) create mode 100644 app/__tests__/components/launch-backing-seed-warning.test.ts 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 000000000..28d09c0d6 --- /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 bf59780bd..fedd755ba 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 7357eb21f..f7406b8dd 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 30ab9327a..1c8203f4e 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,