From cf9f43a16ffa6e43a1bca6c71f37d220ee1ec0d7 Mon Sep 17 00:00:00 2001 From: Unclebaffa Date: Mon, 17 Aug 2026 10:36:08 +0100 Subject: [PATCH 1/4] fix(frontend): validate localStorage campaign draft state on hydration --- .gitignore | 4 +- .../business/campaign-wizard-modal.test.ts | 88 +++++++++++++++++++ .../business/campaign-wizard-modal.tsx | 73 +++++++++++++-- 3 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 apps/frontend/components/dashboard/business/campaign-wizard-modal.test.ts diff --git a/.gitignore b/.gitignore index 509ef03..8a57aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,11 +21,13 @@ apps/backend/dist/ # production build -# foundry +# foundry / soroban packages/contracts/out/ packages/contracts/cache/ packages/contracts/broadcast/ packages/contracts/lib/ +packages/contracts/target/ +packages/contracts/test_snapshots/ # misc .DS_Store diff --git a/apps/frontend/components/dashboard/business/campaign-wizard-modal.test.ts b/apps/frontend/components/dashboard/business/campaign-wizard-modal.test.ts new file mode 100644 index 0000000..bc2f903 --- /dev/null +++ b/apps/frontend/components/dashboard/business/campaign-wizard-modal.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert"; +import { test, describe } from "node:test"; +import { isValidWizardState, WizardState } from "./campaign-wizard-modal"; + +const validState: WizardState = { + currentStep: 1, + brief: { + title: "Test Campaign", + description: "A valid campaign description", + imageUrl: null, + platforms: ["Twitter/X", "Telegram"], + startDate: "2026-09-01", + endDate: "2026-09-30", + campaignType: "awareness", + }, + targeting: { + categories: ["DeFi", "Web3", "Gaming"], + minAudienceSize: "10k+", + regions: ["Global"], + requirements: "Must have active followers", + }, + budget: { + asset: "USDC", + totalBudget: 500, + creatorSlots: 5, + }, + proof: { + contentFormats: ["Thread", "Video"], + deliverables: "1 thread with screenshot", + submissionDeadline: "2026-09-15", + verificationType: "manual", + linkSocialPost: true, + viewCountThreshold: false, + }, +}; + +describe("isValidWizardState", () => { + test("returns true for a valid state object", () => { + assert.strictEqual(isValidWizardState(validState), true); + }); + + test("returns false for non-object values", () => { + assert.strictEqual(isValidWizardState(null), false); + assert.strictEqual(isValidWizardState(undefined), false); + assert.strictEqual(isValidWizardState("string"), false); + assert.strictEqual(isValidWizardState(123), false); + }); + + test("returns false when currentStep is missing or invalid", () => { + assert.strictEqual(isValidWizardState({ ...validState, currentStep: 0 }), false); + assert.strictEqual(isValidWizardState({ ...validState, currentStep: 6 }), false); + assert.strictEqual(isValidWizardState({ ...validState, currentStep: "1" }), false); + assert.strictEqual(isValidWizardState({ ...validState, currentStep: NaN }), false); + }); + + test("returns false when budget fields are malformed or non-number", () => { + const invalidBudget1 = { + ...validState, + budget: { ...validState.budget, totalBudget: "500" as unknown as number }, + }; + assert.strictEqual(isValidWizardState(invalidBudget1), false); + + const invalidBudget2 = { + ...validState, + budget: { ...validState.budget, totalBudget: NaN }, + }; + assert.strictEqual(isValidWizardState(invalidBudget2), false); + + const invalidBudget3 = { + ...validState, + budget: { ...validState.budget, creatorSlots: 0 }, + }; + assert.strictEqual(isValidWizardState(invalidBudget3), false); + }); + + test("returns false when brief platforms is not an array of strings", () => { + const invalidPlatforms = { + ...validState, + brief: { ...validState.brief, platforms: "Twitter" as unknown as string[] }, + }; + assert.strictEqual(isValidWizardState(invalidPlatforms), false); + }); + + test("returns false when sections are missing or null", () => { + const missingBrief = { ...validState, brief: null }; + assert.strictEqual(isValidWizardState(missingBrief), false); + }); +}); diff --git a/apps/frontend/components/dashboard/business/campaign-wizard-modal.tsx b/apps/frontend/components/dashboard/business/campaign-wizard-modal.tsx index 26ee9c1..6eae957 100644 --- a/apps/frontend/components/dashboard/business/campaign-wizard-modal.tsx +++ b/apps/frontend/components/dashboard/business/campaign-wizard-modal.tsx @@ -20,7 +20,7 @@ const STEPS = [ const DRAFT_KEY = "adsbazaar_campaign_draft"; -interface WizardState { +export interface WizardState { currentStep: number; brief: { title: string; @@ -52,6 +52,54 @@ interface WizardState { }; } +export function isValidWizardState(obj: unknown): obj is WizardState { + if (typeof obj !== "object" || obj === null) return false; + const draft = obj as Record; + + if ( + typeof draft.currentStep !== "number" || + !Number.isInteger(draft.currentStep) || + draft.currentStep < 1 || + draft.currentStep > 5 + ) { + return false; + } + + if (typeof draft.brief !== "object" || draft.brief === null) return false; + const brief = draft.brief as Record; + if (typeof brief.title !== "string") return false; + if (typeof brief.description !== "string") return false; + if (brief.imageUrl !== null && typeof brief.imageUrl !== "string") return false; + if (!Array.isArray(brief.platforms) || !brief.platforms.every((p) => typeof p === "string")) return false; + if (typeof brief.startDate !== "string") return false; + if (typeof brief.endDate !== "string") return false; + if (typeof brief.campaignType !== "string") return false; + + if (typeof draft.targeting !== "object" || draft.targeting === null) return false; + const targeting = draft.targeting as Record; + if (!Array.isArray(targeting.categories) || !targeting.categories.every((c) => typeof c === "string")) return false; + if (typeof targeting.minAudienceSize !== "string") return false; + if (!Array.isArray(targeting.regions) || !targeting.regions.every((r) => typeof r === "string")) return false; + if (typeof targeting.requirements !== "string") return false; + + if (typeof draft.budget !== "object" || draft.budget === null) return false; + const budget = draft.budget as Record; + if (typeof budget.asset !== "string") return false; + if (typeof budget.totalBudget !== "number" || Number.isNaN(budget.totalBudget) || budget.totalBudget < 0) return false; + if (typeof budget.creatorSlots !== "number" || Number.isNaN(budget.creatorSlots) || budget.creatorSlots < 1) return false; + + if (typeof draft.proof !== "object" || draft.proof === null) return false; + const proof = draft.proof as Record; + if (!Array.isArray(proof.contentFormats) || !proof.contentFormats.every((f) => typeof f === "string")) return false; + if (typeof proof.deliverables !== "string") return false; + if (typeof proof.submissionDeadline !== "string") return false; + if (typeof proof.verificationType !== "string") return false; + if (typeof proof.linkSocialPost !== "boolean") return false; + if (typeof proof.viewCountThreshold !== "boolean") return false; + + return true; +} + const initialState: WizardState = { currentStep: 1, brief: { @@ -134,15 +182,26 @@ export function CampaignWizardModal() { try { const saved = localStorage.getItem(DRAFT_KEY); if (saved) { - const parsed = JSON.parse(saved) as WizardState; - // Strip blob: URLs from restored draft — they're invalid after reload - if (parsed.brief.imageUrl?.startsWith('blob:')) { - parsed.brief.imageUrl = null; + const parsed = JSON.parse(saved); + if (isValidWizardState(parsed)) { + // Strip blob: URLs from restored draft — they're invalid after reload + if (parsed.brief.imageUrl?.startsWith("blob:")) { + parsed.brief.imageUrl = null; + } + setState(parsed); + } else { + // Discard corrupt or mismatched draft + localStorage.removeItem(DRAFT_KEY); + setState(initialState); } - setState(parsed); } } catch { - // ignore + try { + localStorage.removeItem(DRAFT_KEY); + } catch { + // ignore + } + setState(initialState); } setHydrated(true); }, [isOpen]); From dfac38982657116680fa93a7a25a3bccb72ef7f4 Mon Sep 17 00:00:00 2001 From: Unclebaffa Date: Wed, 19 Aug 2026 17:38:56 +0100 Subject: [PATCH 2/4] fix(frontend): validate totalBudget against NaN in campaign wizard step --- .../components/campaigns/new/steps/step-budget.tsx | 13 +++++++++---- .../campaigns/new/steps/step-review-fund.tsx | 8 ++++++-- .../dashboard/business/campaign-wizard-modal.tsx | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/frontend/components/campaigns/new/steps/step-budget.tsx b/apps/frontend/components/campaigns/new/steps/step-budget.tsx index 4df3698..7cd94fb 100644 --- a/apps/frontend/components/campaigns/new/steps/step-budget.tsx +++ b/apps/frontend/components/campaigns/new/steps/step-budget.tsx @@ -22,12 +22,17 @@ export function StepBudget({ data, onChange, errors }: StepBudgetProps) { onChange({ ...data, ...patch }); } + const validTotalBudget = + Number.isFinite(data.totalBudget) && data.totalBudget > 0 + ? data.totalBudget + : 0; + const payoutPerCreator = - data.totalBudget > 0 && data.creatorSlots > 0 - ? (data.totalBudget / data.creatorSlots).toFixed(2) + validTotalBudget > 0 && data.creatorSlots > 0 + ? (validTotalBudget / data.creatorSlots).toFixed(2) : "0.00"; - const platformFee = data.totalBudget * 0.005; + const platformFee = validTotalBudget * 0.005; return (
@@ -138,7 +143,7 @@ export function StepBudget({ data, onChange, errors }: StepBudgetProps) {

Upon campaign launch, your selected budget of{" "} - {data.totalBudget.toLocaleString()} {data.asset} + {validTotalBudget.toLocaleString()} {data.asset} {" "} will be locked in a secure{" "} 0 + ? budget.totalBudget + : 0; + const creatorPool = validBudget; + const platformFee = validBudget * 0.005; const networkGas = 0.0001; const total = creatorPool + platformFee + networkGas; diff --git a/apps/frontend/components/dashboard/business/campaign-wizard-modal.tsx b/apps/frontend/components/dashboard/business/campaign-wizard-modal.tsx index 6eae957..5f01c4f 100644 --- a/apps/frontend/components/dashboard/business/campaign-wizard-modal.tsx +++ b/apps/frontend/components/dashboard/business/campaign-wizard-modal.tsx @@ -154,7 +154,7 @@ function validateStep(step: number, state: WizardState): Record if (step === 3) { if (!state.budget.asset) errors.asset = "Select a payment asset."; - if (state.budget.totalBudget <= 0) errors.totalBudget = "Budget must be greater than 0."; + if (!Number.isFinite(state.budget.totalBudget) || state.budget.totalBudget <= 0) errors.totalBudget = "Budget must be greater than 0."; if (state.budget.creatorSlots < 1) errors.creatorSlots = "At least 1 creator slot is required."; } From fa920f19aeef39dff9880a7784b8a9ce006e24fe Mon Sep 17 00:00:00 2001 From: Unclebaffa Date: Wed, 19 Aug 2026 17:44:50 +0100 Subject: [PATCH 3/4] test: add vitest.config.mjs with path alias and node:test mapping --- vitest.config.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 vitest.config.mjs diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..a1175ce --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,13 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default { + test: { + alias: { + "@": path.resolve(__dirname, "./apps/frontend"), + "node:test": "vitest", + }, + }, +}; From b511ff86ac10371966de2533e0e891d23b6823e3 Mon Sep 17 00:00:00 2001 From: Unclebaffa Date: Wed, 19 Aug 2026 17:55:54 +0100 Subject: [PATCH 4/4] fix(frontend): update eslint.config.mjs flat config and lint script --- apps/frontend/eslint.config.mjs | 26 +++++++++++--------------- apps/frontend/package.json | 2 +- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/apps/frontend/eslint.config.mjs b/apps/frontend/eslint.config.mjs index c85fb67..2b4ccc0 100644 --- a/apps/frontend/eslint.config.mjs +++ b/apps/frontend/eslint.config.mjs @@ -1,16 +1,12 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), +import nextConfig from "eslint-config-next"; + +export default [ + ...nextConfig, + { + rules: { + "react-hooks/set-state-in-effect": "off", + "react-hooks/immutability": "off", + "react/no-unescaped-entities": "off", + }, + }, ]; - -export default eslintConfig; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 36dff86..b044673 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,7 +6,7 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint ." }, "dependencies": { "@stellar/freighter-api": "^6.0.1",