diff --git a/readme.md b/readme.md
index 1ff4bc95b4..8f5a853540 100644
--- a/readme.md
+++ b/readme.md
@@ -1,10 +1,34 @@
-# 99Tech Code Challenge #1 #
+## Solutions (Frontend Engineer application)
-Note that if you fork this repository, your responses may be publicly linked to this repo.
-Please submit your application along with the solutions attached or linked.
+Applying for the **Frontend Engineer** role, so Problems 1–3 were attempted;
+Problems 4–5 are left as-is (out of scope for this role).
-It is important that you minimally attempt the problems, even if you do not arrive at a working solution.
+### [Problem 1 — Three ways to sum to n](src/problem1/sum_to_n.js)
-## Submission ##
-You can either provide a link to an online repository, attach the solution in your application, or whichever method you prefer.
-We're cool as long as we can view your solution without any pain.
+Three implementations (iterative loop, closed-form Gauss formula, recursive),
+plus a `runTestCases()` function that checks all three against a shared set
+of inputs, including large values where the recursive version is
+intentionally skipped (it would overflow the call stack).
+
+```bash
+node src/problem1/sum_to_n.js
+```
+
+### [Problem 2 — Fancy Form](src/problem2/)
+
+Currency swap form built with React + TypeScript + Vite. Uses live prices
+from `interview.switcheo.com/prices.json` and token icons from
+[Switcheo/token-icons](https://github.com/Switcheo/token-icons). See
+[src/problem2/README.md](src/problem2/README.md) for details and how it's
+structured.
+
+```bash
+cd src/problem2
+npm install
+npm run dev
+```
+
+### [Problem 3 — Messy React](src/problem3/analysis.md)
+
+Write-up of the inefficiencies/anti-patterns found in the given code block,
+plus a refactored version with an explanation of each change.
diff --git a/src/problem1/.keep b/src/problem1/.keep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/problem1/sum_to_n.js b/src/problem1/sum_to_n.js
new file mode 100644
index 0000000000..14737fc2be
--- /dev/null
+++ b/src/problem1/sum_to_n.js
@@ -0,0 +1,82 @@
+/**
+ * Problem 1: Three ways to sum to n
+ *
+ * Input: n - any integer
+ * Assumption: the result is always < Number.MAX_SAFE_INTEGER.
+ * Output: sum of integers from 1 to n, e.g. sum_to_n(5) === 1+2+3+4+5 === 15.
+ *
+ * These implementations assume n >= 0, consistent with the "sum to n" spec
+ * (there is no natural summation from 1 to a negative n).
+ */
+
+// A. Iterative loop — O(n) time, O(1) space.
+// Most straightforward and easy to read; fine for small/medium n.
+var sum_to_n_a = function (n) {
+ let sum = 0;
+ for (let i = 1; i <= n; i++) {
+ sum += i;
+ }
+ return sum;
+};
+
+// B. Mathematical formula (Gauss' sum) — O(1) time, O(1) space.
+// The fastest option, no loop needed: n * (n + 1) / 2.
+var sum_to_n_b = function (n) {
+ return (n * (n + 1)) / 2;
+};
+
+// C. Recursive — O(n) time, O(n) space (call stack).
+// Demonstrates a functional/recursive approach. Not suitable for very large n
+// (risks a stack overflow), but included as a distinct technique from A and B.
+var sum_to_n_c = function (n) {
+ if (n <= 0) return 0;
+ return n + sum_to_n_c(n - 1);
+};
+
+// Runs a shared set of test cases through all three implementations and
+// checks that they agree, so a regression in any one of them is obvious.
+//
+// sum_to_n_c is recursive, so it's only exercised for n small enough to fit
+// the call stack; sum_to_n_a/b (loop/formula) are also checked against much
+// larger n, where a naive recursive approach would overflow the stack.
+function runTestCases() {
+ const recursionSafeCases = [0, 1, 5, 10, 100, 1000];
+ const largeOnlyCases = [1_000_000, 12345678];
+ let allPassed = true;
+
+ for (const n of recursionSafeCases) {
+ const a = sum_to_n_a(n);
+ const b = sum_to_n_b(n);
+ const c = sum_to_n_c(n);
+ const expected = (n * (n + 1)) / 2;
+ const passed = a === expected && b === expected && c === expected;
+ allPassed = allPassed && passed;
+
+ console.log(
+ `sum_to_n(${n}) => a=${a} b=${b} c=${c} expected=${expected} ${
+ passed ? "PASS" : "FAIL"
+ }`
+ );
+ }
+
+ for (const n of largeOnlyCases) {
+ const a = sum_to_n_a(n);
+ const b = sum_to_n_b(n);
+ const expected = (n * (n + 1)) / 2;
+ const passed = a === expected && b === expected;
+ allPassed = allPassed && passed;
+
+ console.log(
+ `sum_to_n(${n}) => a=${a} b=${b} expected=${expected} (c skipped: would overflow the call stack) ${
+ passed ? "PASS" : "FAIL"
+ }`
+ );
+ }
+
+ console.log(allPassed ? "\nAll test cases passed." : "\nSome test cases failed.");
+ return allPassed;
+}
+
+runTestCases();
+
+module.exports = { sum_to_n_a, sum_to_n_b, sum_to_n_c, runTestCases };
diff --git a/src/problem2/.gitignore b/src/problem2/.gitignore
new file mode 100644
index 0000000000..a547bf36d8
--- /dev/null
+++ b/src/problem2/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/src/problem2/.oxlintrc.json b/src/problem2/.oxlintrc.json
new file mode 100644
index 0000000000..6fa991dad2
--- /dev/null
+++ b/src/problem2/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "typescript", "oxc"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
diff --git a/src/problem2/README.md b/src/problem2/README.md
new file mode 100644
index 0000000000..b2b93b0579
--- /dev/null
+++ b/src/problem2/README.md
@@ -0,0 +1,47 @@
+# Fancy Form — Currency Swap
+
+A currency swap form built with **React + TypeScript + Vite**.
+
+## Run it
+
+```bash
+npm install
+npm run dev
+```
+
+## What it does
+
+- Fetches live prices from `https://interview.switcheo.com/prices.json` on load.
+ The feed has duplicate/stale rows per currency, so only the latest,
+ positive-price entry per currency is kept. A bundled snapshot
+ (`src/prices.json`) is used as a fallback if the live fetch fails.
+- Token icons come from [Switcheo/token-icons](https://github.com/Switcheo/token-icons),
+ copied into `public/tokens/`. Any currency without a matching icon falls
+ back to a circular badge with its initials (see `TokenIcon.tsx`).
+- Amount to receive is derived automatically from the live exchange rate —
+ no separate "calculate" step.
+- Input validation: the send amount must be a positive number, and the two
+ sides can't both be the same token (enforced by disabling that option in
+ the token picker).
+- A ⇅ button reverses the swap direction, carrying over the current output
+ amount as the new input.
+- Submitting simulates a backend call (`setTimeout`) so the loading spinner
+ on the submit button is visible, then shows a success message — there's no
+ real backend for this challenge.
+- Token pickers are searchable dropdowns (type to filter by symbol).
+
+## Structure
+
+```
+src/
+ components/
+ SwapForm.tsx - presentational: renders the hook's state
+ TokenSelect.tsx - searchable token dropdown
+ TokenIcon.tsx - token icon with initials fallback
+ hooks/useSwapForm.ts - all form state, validation, and submit logic
+ utils/
+ loadPrices.ts - fetch + dedupe the price feed
+ format.ts - number formatting
+ data/fallbackPrices.ts
+ prices.json - bundled fallback snapshot
+```
diff --git a/src/problem2/index.html b/src/problem2/index.html
index 4058a68bff..2cdfca048c 100644
--- a/src/problem2/index.html
+++ b/src/problem2/index.html
@@ -1,27 +1,13 @@
-
-
-
+ );
+}
diff --git a/src/problem2/src/data/fallbackPrices.ts b/src/problem2/src/data/fallbackPrices.ts
new file mode 100644
index 0000000000..9ba7b3636e
--- /dev/null
+++ b/src/problem2/src/data/fallbackPrices.ts
@@ -0,0 +1,7 @@
+import type { TokenPrice } from "../types";
+import raw from "../prices.json";
+
+// Bundled snapshot of https://interview.switcheo.com/prices.json,
+// already deduplicated to the latest entry per currency.
+// Used only if the live fetch at runtime fails (offline demo, CORS, etc).
+export const fallbackPrices = raw as TokenPrice[];
diff --git a/src/problem2/src/hooks/useSwapForm.ts b/src/problem2/src/hooks/useSwapForm.ts
new file mode 100644
index 0000000000..31cd5f0c40
--- /dev/null
+++ b/src/problem2/src/hooks/useSwapForm.ts
@@ -0,0 +1,137 @@
+import { useEffect, useMemo, useState } from "react";
+import type { PriceMap } from "../types";
+import { loadPrices } from "../utils/loadPrices";
+import { formatAmount } from "../utils/format";
+
+const DEFAULT_FROM = "ETH";
+const DEFAULT_TO = "USDC";
+
+type Status = "loading" | "ready" | "error";
+
+export function useSwapForm() {
+ const [prices, setPrices] = useState({});
+ const [status, setStatus] = useState("loading");
+
+ const [fromCurrency, setFromCurrency] = useState(DEFAULT_FROM);
+ const [toCurrency, setToCurrency] = useState(DEFAULT_TO);
+ const [fromAmount, setFromAmount] = useState("");
+
+ const [submitting, setSubmitting] = useState(false);
+ const [successMessage, setSuccessMessage] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ loadPrices()
+ .then((map) => {
+ if (cancelled) return;
+ setPrices(map);
+ setStatus("ready");
+ // Fall back to whatever two currencies actually have prices,
+ // in case the defaults are ever missing from the feed.
+ const currencies = Object.keys(map);
+ if (!map[DEFAULT_FROM] || !map[DEFAULT_TO]) {
+ setFromCurrency(currencies[0] ?? "");
+ setToCurrency(currencies[1] ?? currencies[0] ?? "");
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setStatus("error");
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const currencies = useMemo(() => Object.keys(prices).sort(), [prices]);
+
+ const rate = useMemo(() => {
+ const fromPrice = prices[fromCurrency]?.price;
+ const toPrice = prices[toCurrency]?.price;
+ if (!fromPrice || !toPrice) return null;
+ return fromPrice / toPrice;
+ }, [prices, fromCurrency, toCurrency]);
+
+ const parsedAmount = Number(fromAmount);
+ const hasAmount = fromAmount.trim().length > 0;
+ const isAmountValid = hasAmount && Number.isFinite(parsedAmount) && parsedAmount > 0;
+
+ const toAmount = rate !== null && isAmountValid ? parsedAmount * rate : null;
+
+ const error = useMemo(() => {
+ if (!hasAmount) return null;
+ if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
+ return "Enter an amount greater than 0.";
+ }
+ return null;
+ }, [hasAmount, parsedAmount]);
+
+ const canSubmit =
+ status === "ready" &&
+ isAmountValid &&
+ rate !== null &&
+ fromCurrency !== toCurrency &&
+ !submitting;
+
+ function handleSwapDirection() {
+ setFromCurrency(toCurrency);
+ setToCurrency(fromCurrency);
+ setFromAmount(toAmount !== null ? String(Number(toAmount.toFixed(6))) : "");
+ setSuccessMessage(null);
+ }
+
+ function handleFromCurrencyChange(currency: string) {
+ setFromCurrency(currency);
+ setSuccessMessage(null);
+ }
+
+ function handleToCurrencyChange(currency: string) {
+ setToCurrency(currency);
+ setSuccessMessage(null);
+ }
+
+ function handleAmountChange(value: string) {
+ // Allow only digits and a single decimal point while typing.
+ if (value !== "" && !/^\d*\.?\d*$/.test(value)) return;
+ setFromAmount(value);
+ setSuccessMessage(null);
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!canSubmit) return;
+
+ setSubmitting(true);
+ setSuccessMessage(null);
+
+ // No real backend for this challenge — simulate network latency so the
+ // loading state on the submit button is visible.
+ window.setTimeout(() => {
+ setSubmitting(false);
+ setSuccessMessage(
+ `Swapped ${formatAmount(parsedAmount)} ${fromCurrency} for ${formatAmount(
+ toAmount ?? 0
+ )} ${toCurrency}.`
+ );
+ setFromAmount("");
+ }, 1200);
+ }
+
+ return {
+ status,
+ currencies,
+ fromCurrency,
+ toCurrency,
+ fromAmount,
+ toAmount,
+ rate,
+ error,
+ canSubmit,
+ submitting,
+ successMessage,
+ handleSwapDirection,
+ handleFromCurrencyChange,
+ handleToCurrencyChange,
+ handleAmountChange,
+ handleSubmit,
+ };
+}
diff --git a/src/problem2/src/index.css b/src/problem2/src/index.css
new file mode 100644
index 0000000000..3a74dc10be
--- /dev/null
+++ b/src/problem2/src/index.css
@@ -0,0 +1,34 @@
+:root {
+ color-scheme: light dark;
+ --bg-gradient-1: #eef1ff;
+ --bg-gradient-2: #f7f2ff;
+ --card-bg: #ffffff;
+ --field-bg: #f4f5fb;
+ --field-bg-hover: #ebedf7;
+ --text-primary: #1a1a2e;
+ --text-secondary: #6b6f8d;
+ --accent: #6c5ce7;
+ --accent-hover: #5a48d6;
+ --error: #e5484d;
+ --success: #1b8a5a;
+ --border: #e3e5f0;
+ --shadow: 0 20px 60px -20px rgba(60, 60, 120, 0.35);
+
+ font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ background: linear-gradient(160deg, var(--bg-gradient-1), var(--bg-gradient-2));
+ color: var(--text-primary);
+}
diff --git a/src/problem2/src/main.tsx b/src/problem2/src/main.tsx
new file mode 100644
index 0000000000..bef5202a32
--- /dev/null
+++ b/src/problem2/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/src/problem2/src/prices.json b/src/problem2/src/prices.json
new file mode 100644
index 0000000000..9f6a6b478f
--- /dev/null
+++ b/src/problem2/src/prices.json
@@ -0,0 +1,162 @@
+[
+ {
+ "currency": "ATOM",
+ "date": "2023-08-29T07:10:50.000Z",
+ "price": 7.186657333333334
+ },
+ {
+ "currency": "BLUR",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.20811525423728813
+ },
+ {
+ "currency": "BUSD",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.999183113
+ },
+ {
+ "currency": "ETH",
+ "date": "2023-08-29T07:10:52.000Z",
+ "price": 1645.9337373737374
+ },
+ {
+ "currency": "EVMOS",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.06246181355932203
+ },
+ {
+ "currency": "GMX",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 36.345114372881355
+ },
+ {
+ "currency": "IBCX",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 41.26811355932203
+ },
+ {
+ "currency": "IRIS",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.0177095593220339
+ },
+ {
+ "currency": "KUJI",
+ "date": "2023-08-29T07:10:45.000Z",
+ "price": 0.675
+ },
+ {
+ "currency": "LSI",
+ "date": "2023-08-29T07:10:50.000Z",
+ "price": 67.69661525423729
+ },
+ {
+ "currency": "LUNA",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.40955638983050846
+ },
+ {
+ "currency": "OKB",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 42.97562059322034
+ },
+ {
+ "currency": "OKT",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 13.561577966101694
+ },
+ {
+ "currency": "OSMO",
+ "date": "2023-08-29T07:10:50.000Z",
+ "price": 0.3772974333333333
+ },
+ {
+ "currency": "RATOM",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 10.250918915254237
+ },
+ {
+ "currency": "STATOM",
+ "date": "2023-08-29T07:10:45.000Z",
+ "price": 8.512162050847458
+ },
+ {
+ "currency": "STEVMOS",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.07276706779661017
+ },
+ {
+ "currency": "STLUNA",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.44232210169491526
+ },
+ {
+ "currency": "STOSMO",
+ "date": "2023-08-29T07:10:45.000Z",
+ "price": 0.431318
+ },
+ {
+ "currency": "STRD",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.7386553389830508
+ },
+ {
+ "currency": "SWTH",
+ "date": "2023-08-29T07:10:45.000Z",
+ "price": 0.004039850455012084
+ },
+ {
+ "currency": "USC",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.994
+ },
+ {
+ "currency": "USD",
+ "date": "2023-08-29T07:10:30.000Z",
+ "price": 1
+ },
+ {
+ "currency": "USDC",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.989832
+ },
+ {
+ "currency": "WBTC",
+ "date": "2023-08-29T07:10:52.000Z",
+ "price": 26002.82202020202
+ },
+ {
+ "currency": "YieldUSD",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 1.0290847966101695
+ },
+ {
+ "currency": "ZIL",
+ "date": "2023-08-29T07:10:50.000Z",
+ "price": 0.01651813559322034
+ },
+ {
+ "currency": "ampLUNA",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.49548589830508477
+ },
+ {
+ "currency": "axlUSDC",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.989832
+ },
+ {
+ "currency": "bNEO",
+ "date": "2023-08-29T07:10:50.000Z",
+ "price": 7.1282679
+ },
+ {
+ "currency": "rSWTH",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 0.00408771
+ },
+ {
+ "currency": "wstETH",
+ "date": "2023-08-29T07:10:40.000Z",
+ "price": 1872.2579742372882
+ }
+]
\ No newline at end of file
diff --git a/src/problem2/src/types.ts b/src/problem2/src/types.ts
new file mode 100644
index 0000000000..18750c5116
--- /dev/null
+++ b/src/problem2/src/types.ts
@@ -0,0 +1,7 @@
+export interface TokenPrice {
+ currency: string;
+ price: number;
+ date: string;
+}
+
+export type PriceMap = Record;
diff --git a/src/problem2/src/utils/format.ts b/src/problem2/src/utils/format.ts
new file mode 100644
index 0000000000..0514b3a663
--- /dev/null
+++ b/src/problem2/src/utils/format.ts
@@ -0,0 +1,4 @@
+export function formatAmount(value: number): string {
+ if (!Number.isFinite(value)) return "";
+ return value.toLocaleString(undefined, { maximumFractionDigits: 6 });
+}
diff --git a/src/problem2/src/utils/loadPrices.ts b/src/problem2/src/utils/loadPrices.ts
new file mode 100644
index 0000000000..99b3a1cad7
--- /dev/null
+++ b/src/problem2/src/utils/loadPrices.ts
@@ -0,0 +1,30 @@
+import type { PriceMap, TokenPrice } from "../types";
+import { fallbackPrices } from "../data/fallbackPrices";
+
+const PRICES_URL = "https://interview.switcheo.com/prices.json";
+
+// The feed contains duplicate/stale rows per currency (and a few zero/undefined
+// prices). Keep only the most recent, positive-price entry for each currency.
+function toLatestPriceMap(entries: TokenPrice[]): PriceMap {
+ const latest: PriceMap = {};
+ for (const entry of entries) {
+ if (!entry.price || entry.price <= 0) continue;
+ const existing = latest[entry.currency];
+ if (!existing || new Date(entry.date) > new Date(existing.date)) {
+ latest[entry.currency] = entry;
+ }
+ }
+ return latest;
+}
+
+export async function loadPrices(): Promise {
+ try {
+ const res = await fetch(PRICES_URL);
+ if (!res.ok) throw new Error(`Price feed responded with ${res.status}`);
+ const data: TokenPrice[] = await res.json();
+ return toLatestPriceMap(data);
+ } catch (err) {
+ console.warn("Falling back to bundled price snapshot:", err);
+ return toLatestPriceMap(fallbackPrices);
+ }
+}
diff --git a/src/problem2/style.css b/src/problem2/style.css
deleted file mode 100644
index 915af91c72..0000000000
--- a/src/problem2/style.css
+++ /dev/null
@@ -1,8 +0,0 @@
-body {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
- min-width: 360px;
- font-family: Arial, Helvetica, sans-serif;
-}
diff --git a/src/problem2/tsconfig.app.json b/src/problem2/tsconfig.app.json
new file mode 100644
index 0000000000..6830b6f759
--- /dev/null
+++ b/src/problem2/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "allowArbitraryExtensions": true,
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/src/problem2/tsconfig.json b/src/problem2/tsconfig.json
new file mode 100644
index 0000000000..1ffef600d9
--- /dev/null
+++ b/src/problem2/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/src/problem2/tsconfig.node.json b/src/problem2/tsconfig.node.json
new file mode 100644
index 0000000000..8455dcbc2c
--- /dev/null
+++ b/src/problem2/tsconfig.node.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "module": "nodenext",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/src/problem2/vite.config.ts b/src/problem2/vite.config.ts
new file mode 100644
index 0000000000..8b0f57b91a
--- /dev/null
+++ b/src/problem2/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/src/problem3/.keep b/src/problem3/.keep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/problem3/analysis.md b/src/problem3/analysis.md
new file mode 100644
index 0000000000..faf5b3d4ac
--- /dev/null
+++ b/src/problem3/analysis.md
@@ -0,0 +1,170 @@
+# Problem 3: Messy React — Issues Found
+
+## Computational inefficiencies / anti-patterns
+
+1. **`lhsPriority` is undefined — the filter is broken (bug, not just inefficient).**
+ The filter callback reads `lhsPriority`, but only `balancePriority` was
+ declared. This throws a `ReferenceError` at runtime (or silently returns
+ `undefined`/falsy under loose settings), so the filter never behaves as
+ intended.
+
+2. **The filter logic is inverted relative to what it's named for.**
+ `if (balance.amount <= 0) return true` keeps balances with **zero or
+ negative** amount and drops everything with a positive amount. A wallet
+ page almost certainly wants to display balances the user actually holds
+ (`amount > 0`), so the condition is backwards.
+
+3. **`getPriority` takes `blockchain: any`.**
+ `any` throws away type safety for the one function driving the sort/filter
+ logic. A `Blockchain` union/string-literal type would let TypeScript catch
+ typos and missing cases at compile time.
+
+4. **`getPriority` is redefined on every render.**
+ It's a pure function of `blockchain` with no dependency on component state
+ or props, so it doesn't need to live inside the component — it's
+ recreated (a new function reference) on every render for no benefit and,
+ because it's not memoized, forces `useMemo`/`useCallback` consumers that
+ depend on it to also recompute needlessly if it were ever added as a
+ dependency. It also encodes blockchain priority as a hardcoded
+ switch — a lookup object (`Record`) is more scalable and
+ removes the fallthrough-prone `switch`.
+
+5. **`useMemo` dependency array includes `prices` but the computation never
+ uses `prices`.**
+ `sortedBalances` is derived only from `balances` and `getPriority`.
+ Including `prices` causes the (already expensive, unstable) filter+sort to
+ re-run every time prices update — e.g. on every price tick — even though
+ the result wouldn't change. This is wasted CPU work.
+
+6. **The `sort` comparator has no `return` for the equal-priority case.**
+ When `leftPriority === rightPriority`, the comparator falls off the end
+ and returns `undefined`. Modern engines tolerate this, but it's undefined
+ behavior per the spec and should return `0` explicitly for a stable,
+ correct sort.
+
+7. **`sortedBalances.filter/sort` is recomputed, then mapped twice
+ (`formattedBalances`, then `rows` from `sortedBalances` again).**
+ `formattedBalances` is computed but never used — `rows` maps over
+ `sortedBalances` again and recomputes `toFixed()` inline instead of reusing
+ `formattedBalances`. This is dead code plus a wasted pass over the array.
+
+8. **`rows` maps `sortedBalances: WalletBalance[]` but types the callback
+ parameter as `FormattedWalletBalance`.**
+ `WalletBalance` has no `formatted` field, so `balance.formatted` is
+ accessing a property TypeScript shouldn't allow — this only "works"
+ because `sortedBalances` is implicitly typed as `any[]` (a consequence of
+ issue #3) or because the annotation is simply wrong and the compiler isn't
+ catching it. Either way, types and runtime data are out of sync.
+
+9. **`getPriority(balance.blockchain)` is called once per item in the filter
+ and again per item in the sort comparator (up to ~2n log n calls) — and a
+ third time isn't needed but nothing is memoized.**
+ For a hot list (wallet balances re-rendering on every price change), it's
+ cheap to precompute `{ ...balance, priority: getPriority(balance.blockchain) }`
+ once and sort/filter on the cached value instead of recalculating.
+
+10. **`key={index}` on ``.**
+ Using the array index as the React key is an anti-pattern for a list that
+ can reorder (which this one explicitly does, via sorting) or have items
+ added/removed — React can misassign state/DOM across re-renders. A stable
+ identifier such as `balance.currency` (assuming currencies are unique per
+ wallet) should be used instead.
+
+11. **`WalletBalance` is missing the `blockchain` field used throughout.**
+ `getPriority(balance.blockchain)` and the sort/filter all reference
+ `balance.blockchain`, but the `WalletBalance` interface only declares
+ `currency` and `amount`. This compiles only because `blockchain` access
+ on an implicitly-`any` value is unchecked — fixing the `any` in
+ `getPriority` (issue #3) would surface this as a real type error that
+ needs a proper fix: adding `blockchain: string` to the interface.
+
+12. **`prices[balance.currency] * balance.amount` isn't guarded against a
+ missing price.**
+ If a currency isn't present in `prices`, this evaluates to `NaN` and
+ renders `usdValue={NaN}` silently instead of handling the missing-price
+ case explicitly (e.g. skip the row, or show a loading/placeholder state).
+
+## Refactored version
+
+```tsx
+import { useMemo } from "react";
+
+interface WalletBalance {
+ currency: string;
+ blockchain: string;
+ amount: number;
+}
+
+interface FormattedWalletBalance extends WalletBalance {
+ formatted: string;
+ usdValue: number;
+}
+
+interface Props extends BoxProps {}
+
+const BLOCKCHAIN_PRIORITY: Record = {
+ Osmosis: 100,
+ Ethereum: 50,
+ Arbitrum: 30,
+ Zilliqa: 20,
+ Neo: 20,
+};
+
+function getPriority(blockchain: string): number {
+ return BLOCKCHAIN_PRIORITY[blockchain] ?? -99;
+}
+
+const WalletPage: React.FC = (props: Props) => {
+ const { children, ...rest } = props;
+ const balances = useWalletBalances();
+ const prices = usePrices();
+
+ const formattedBalances = useMemo(() => {
+ return balances
+ .filter((balance) => getPriority(balance.blockchain) > -99 && balance.amount > 0)
+ .sort((lhs, rhs) => getPriority(rhs.blockchain) - getPriority(lhs.blockchain))
+ .map((balance) => ({
+ ...balance,
+ formatted: balance.amount.toFixed(2),
+ usdValue: (prices[balance.currency] ?? 0) * balance.amount,
+ }));
+ }, [balances, prices]);
+
+ const rows = formattedBalances.map((balance) => (
+
+ ));
+
+ return
{rows}
;
+};
+```
+
+### What changed and why
+
+- **Filter now keeps positive balances with a known priority** (fixes the
+ inverted/broken condition and the `lhsPriority` bug).
+- **`getPriority` is a module-level function backed by a lookup object** —
+ no per-render recreation, no `switch` fallthrough risk, and it's typed
+ (`string -> number`) instead of `any`.
+- **One combined `useMemo`** does filter → sort → format → attach `usdValue`
+ in a single pass over the array, replacing the three separate passes
+ (`sortedBalances`, `formattedBalances`, `rows`) and the dead
+ `formattedBalances` variable.
+- **`useMemo` depends on exactly what it uses** (`balances`, `prices` — since
+ `usdValue` now genuinely depends on `prices`), so it only recomputes when
+ one of those actually changes.
+- **The sort comparator always returns a number**, including the
+ equal-priority case.
+- **`blockchain` is a declared field on `WalletBalance`**, and
+ `FormattedWalletBalance` extends it instead of redeclaring `currency`/
+ `amount`, keeping the two interfaces in sync by construction.
+- **`key={balance.currency}`** replaces `key={index}` for stable identity
+ across re-sorts.
+- **Missing prices default to `0`** instead of silently producing `NaN`,
+ making the missing-price case an explicit, visible value rather than a
+ silent bug.