diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000..2cf33a54fd
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+.git
+dist
+**/.env
+**/.env.*
+src/problem2/.env
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000..f2692a18e2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+package-lock.json
+yarn.lock
+src/problem2/.env
+src/problem2/.env.local
+dist/
+settings.json
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000000..0fe10232bc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "code-challenge",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "vitest",
+ "dev:problem2": "vite --config src/problem2/vite.config.ts",
+ "build:problem2": "vite build --config src/problem2/vite.config.ts",
+ "preview:problem2": "vite preview --config src/problem2/vite.config.ts",
+ "docker:build:problem2": "docker build -f src/problem2/docker/Dockerfile -t code-challenge-problem2 .",
+ "docker:run:problem2": "docker run --rm -p 8080:80 code-challenge-problem2"
+ },
+ "devDependencies": {
+ "@chakra-ui/cli": "^3.35.0",
+ "@types/node": "^25.6.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^4.7.0",
+ "tsx": "^4.21.0",
+ "typescript": "^5.7.3",
+ "vite": "^7.3.2",
+ "vitest": "^3.0.5"
+ },
+ "dependencies": {
+ "@chakra-ui/react": "^3.35.0",
+ "@emotion/react": "^11.14.0",
+ "next-themes": "^0.4.6",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
+ "react-hook-form": "^7.75.0",
+ "react-icons": "^5.6.0"
+ }
+}
diff --git a/src/problem1/sum_to_n.test.ts b/src/problem1/sum_to_n.test.ts
new file mode 100644
index 0000000000..65066f02eb
--- /dev/null
+++ b/src/problem1/sum_to_n.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import { sum_to_n_a, sum_to_n_b, sum_to_n_c } from "./sum_to_n.js";
+
+const implementations = [
+ ["sum_to_n_a", sum_to_n_a],
+ ["sum_to_n_b", sum_to_n_b],
+ ["sum_to_n_c", sum_to_n_c],
+] as const;
+
+describe.each(implementations)("%s", (_, sum) => {
+ it("matches 1 + … + n for small n", () => {
+ expect(sum(5)).toBe(15);
+ expect(sum(1)).toBe(1);
+ expect(sum(10)).toBe(55);
+ });
+
+ it("returns 0 for n < 1", () => {
+ expect(sum(0)).toBe(0);
+ expect(sum(-1)).toBe(0);
+ expect(sum(-100)).toBe(0);
+ });
+
+ it("agrees with naive reference for 0..100", () => {
+ for (let n = 0; n <= 100; n++) {
+ let ref = 0;
+ for (let i = 1; i <= n; i++) ref += i;
+ expect(sum(n)).toBe(ref);
+ }
+ });
+
+ it("rejects non-number at runtime (TS types are erased in JS)", () => {
+ expect(() => sum("5" as unknown as number)).toThrow(TypeError);
+ expect(() => sum("5" as unknown as number)).toThrow(/n must be a number/);
+
+ expect(() => sum(null as unknown as number)).toThrow(TypeError);
+ expect(() => sum(undefined as unknown as number)).toThrow(TypeError);
+
+ expect(() => sum(true as unknown as number)).toThrow(TypeError);
+ expect(() => sum({} as unknown as number)).toThrow(TypeError);
+ expect(() => sum([] as unknown as number)).toThrow(TypeError);
+ });
+
+ it("rejects NaN and infinities", () => {
+ expect(() => sum(NaN)).toThrow(/finite/);
+ expect(() => sum(Number.POSITIVE_INFINITY)).toThrow(/finite/);
+ expect(() => sum(Number.NEGATIVE_INFINITY)).toThrow(/finite/);
+ });
+
+ it("rejects non-integer finite numbers", () => {
+ expect(() => sum(1.5)).toThrow(/integer/);
+ expect(() => sum(-2.25)).toThrow(/integer/);
+ });
+});
+
+describe("implementations agree", () => {
+ it("for a range of n", () => {
+ for (let n = -5; n <= 200; n++) {
+ const a = sum_to_n_a(n);
+ expect(sum_to_n_b(n)).toBe(a);
+ expect(sum_to_n_c(n)).toBe(a);
+ }
+ });
+});
diff --git a/src/problem1/sum_to_n.ts b/src/problem1/sum_to_n.ts
new file mode 100644
index 0000000000..0860e9a9ff
--- /dev/null
+++ b/src/problem1/sum_to_n.ts
@@ -0,0 +1,36 @@
+function asSummationIndex(n: unknown): number {
+ if (typeof n !== "number") {
+ throw new TypeError("n must be a number");
+ }
+ if (!Number.isFinite(n)) {
+ throw new TypeError("n must be finite (not NaN or ±Infinity)");
+ }
+ if (!Number.isInteger(n)) {
+ throw new TypeError("n must be an integer");
+ }
+ return n;
+}
+
+// Iterative. Time: O(n). Space: O(1).
+export const sum_to_n_a = (n: number): number => {
+ const k = asSummationIndex(n);
+ let total = 0;
+ for (let i = 1; i <= k; i++) {
+ total += i;
+ }
+ return total;
+};
+
+// Closed form n(n+1)/2. Time: O(1). Space: O(1).
+export const sum_to_n_b = (n: number): number => {
+ const k = asSummationIndex(n);
+ if (k < 1) return 0;
+ return (k * (k + 1)) / 2;
+};
+
+// Recursive. Time: O(n). Space: O(n) call stack (auxiliary).
+export const sum_to_n_c = (n: number): number => {
+ const k = asSummationIndex(n);
+ if (k < 1) return 0;
+ return k + sum_to_n_c(k - 1);
+};
diff --git a/src/problem2/.env.example b/src/problem2/.env.example
new file mode 100644
index 0000000000..b18d3ec939
--- /dev/null
+++ b/src/problem2/.env.example
@@ -0,0 +1,4 @@
+# Copy to `.env` in this folder (`src/problem2/with-react/`). Vite only exposes variables prefixed with `VITE_`.
+#
+# VITE_PRICES_URL=https://interview.switcheo.com/prices.json
+# VITE_TOKEN_ICONS_BASE_URL=https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens
diff --git a/src/problem2/docker/Dockerfile b/src/problem2/docker/Dockerfile
new file mode 100644
index 0000000000..bc2ba02e6a
--- /dev/null
+++ b/src/problem2/docker/Dockerfile
@@ -0,0 +1,22 @@
+# syntax=docker/dockerfile:1
+
+# Build the Problem 2 (Vite + React) static bundle.
+FROM node:22-alpine AS builder
+WORKDIR /app
+
+COPY package.json ./
+RUN npm install
+
+COPY tsconfig.json vitest.config.ts ./
+COPY src ./src
+
+RUN npm run build:problem2
+
+# Serve prebuilt assets with nginx.
+FROM nginx:1.27-alpine AS runtime
+
+COPY src/problem2/docker/nginx-default.conf /etc/nginx/conf.d/default.conf
+COPY --from=builder /app/dist/problem2-with-react /usr/share/nginx/html
+
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/src/problem2/docker/nginx-default.conf b/src/problem2/docker/nginx-default.conf
new file mode 100644
index 0000000000..77b6ca9de2
--- /dev/null
+++ b/src/problem2/docker/nginx-default.conf
@@ -0,0 +1,13 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+}
diff --git a/src/problem2/index.html b/src/problem2/index.html
index 4058a68bff..e6caa250ce 100644
--- a/src/problem2/index.html
+++ b/src/problem2/index.html
@@ -1,27 +1,20 @@
-
+
+
-
- Fancy Form
-
-
-
+
+
+ Fancy Form — Swap
+
+
+
-
-
-
-
+
+
diff --git a/src/problem2/script.js b/src/problem2/script.js
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/problem2/src/App.tsx b/src/problem2/src/App.tsx
new file mode 100644
index 0000000000..65566b48d5
--- /dev/null
+++ b/src/problem2/src/App.tsx
@@ -0,0 +1,86 @@
+import { Box, Container } from "@chakra-ui/react";
+import { useEffect } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+import { ErrorPanel } from "./components/ErrorPanel";
+import { Footer } from "./components/Footer";
+import { Form } from "./components/form";
+import { Header } from "./components/Header";
+import { LoadPanel } from "./components/LoadPanel";
+import { PRICES_URL } from "./consts";
+import { useFetch } from "./hooks/useFetch";
+import type { TFormValues, TPrice } from "./models";
+
+const shellMaxW = { base: "min(440px, 100%)", md: "min(600px, 100%)" };
+
+export const App = () => {
+ const { invoke, data, error, loading } = useFetch();
+ const methods = useForm({
+ defaultValues: {
+ from: undefined,
+ to: undefined,
+ amountIn: "",
+ data: [],
+ isSubmitting: false,
+ },
+ values: {
+ data: [
+ ...new Map(
+ data
+ ?.toSorted((a, b) => Number(b.date) - Number(a.date))
+ .map?.((x) => [x.currency, { ...x, date: Date.parse(x.date) }]) ??
+ []
+ ).values(),
+ ],
+ from: (data ?? [])?.[0]?.currency,
+ to: (data ?? [])?.[1]?.currency,
+ amountIn: "",
+ isSubmitting: false,
+ },
+ mode: "onChange",
+ reValidateMode: "onChange",
+ });
+
+ useEffect(() => {
+ invoke(PRICES_URL);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ {loading && }
+ {!!error && (
+ invoke(PRICES_URL)}
+ />
+ )}
+ {!loading && !Boolean(error) && }
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/problem2/src/components/ErrorPanel.tsx b/src/problem2/src/components/ErrorPanel.tsx
new file mode 100644
index 0000000000..8a7646bbb3
--- /dev/null
+++ b/src/problem2/src/components/ErrorPanel.tsx
@@ -0,0 +1,34 @@
+import { Box, Text } from "@chakra-ui/react";
+import { Button } from "../ui";
+
+export type ErrorPanelProps = {
+ title: string;
+ detail: string;
+ onRetry: () => void;
+};
+
+export function ErrorPanel({ title, detail, onRetry }: ErrorPanelProps) {
+ return (
+
+
+ {title}
+
+
+ {detail}
+
+
+
+ );
+}
diff --git a/src/problem2/src/components/Footer.tsx b/src/problem2/src/components/Footer.tsx
new file mode 100644
index 0000000000..f7f9ca6581
--- /dev/null
+++ b/src/problem2/src/components/Footer.tsx
@@ -0,0 +1,9 @@
+import { Text } from "@chakra-ui/react";
+
+export function Footer() {
+ return (
+
+ Prices load from a public JSON file. No assets move; confirm runs a short wait then shows a status message.
+
+ );
+}
diff --git a/src/problem2/src/components/Header.tsx b/src/problem2/src/components/Header.tsx
new file mode 100644
index 0000000000..12abec8096
--- /dev/null
+++ b/src/problem2/src/components/Header.tsx
@@ -0,0 +1,42 @@
+import { Box, Flex, Heading, Image, Text } from "@chakra-ui/react";
+
+export function Header() {
+ return (
+
+
+
+
+
+
+ Swap
+
+
+ Exchange assets at live reference rates
+
+
+
+ );
+}
diff --git a/src/problem2/src/components/LoadPanel.tsx b/src/problem2/src/components/LoadPanel.tsx
new file mode 100644
index 0000000000..4fbc62a829
--- /dev/null
+++ b/src/problem2/src/components/LoadPanel.tsx
@@ -0,0 +1,34 @@
+import { Box, Text, Spinner } from "@chakra-ui/react";
+
+export function LoadPanel() {
+ return (
+
+
+
+ Loading markets…
+
+
+ Fetching price data
+
+
+ );
+}
diff --git a/src/problem2/src/components/form/AmountFromSection.tsx b/src/problem2/src/components/form/AmountFromSection.tsx
new file mode 100644
index 0000000000..daaad94837
--- /dev/null
+++ b/src/problem2/src/components/form/AmountFromSection.tsx
@@ -0,0 +1,131 @@
+import { Box, Field, Flex } from "@chakra-ui/react";
+import type React from "react";
+import { Controller, useFormContext } from "react-hook-form";
+import { COMMON_UI_STYLES } from "../../consts";
+import { useSwapUI } from "../../hooks/useSwapUi";
+import type { TFormValues } from "../../models";
+import { NumberInput, Select } from "../../ui";
+import { transformData } from "../../utils";
+
+export const AmountFromSection: React.FC = () => {
+ const {
+ watch,
+ setValue,
+ control,
+ formState: { errors },
+ } = useFormContext();
+ const data = watch("data") ?? [];
+ const selectList = transformData(data);
+ const from = watch("from") as string;
+ const to = watch("to") as string;
+ const amountIn = watch("amountIn") as string;
+ const isSubmitting = watch("isSubmitting");
+
+ const { fromPriceHint } = useSwapUI(data, from, to, amountIn);
+
+ return (
+
+
+
+
+ You pay
+
+
+ {fromPriceHint}
+
+
+
+
+
+
+
+ Amount to send
+
+ !(val && +val > 0)
+ ? "Amount must be greater than zero"
+ : true,
+ },
+ required: {
+ value: true,
+ message: "Enter an amount to swap.",
+ },
+ }}
+ render={({ field }) => (
+
+ )}
+ />
+
+
+
+ {errors.amountIn?.message ?? ""}
+
+
+
+
+ );
+};
diff --git a/src/problem2/src/components/form/AmountToSection.tsx b/src/problem2/src/components/form/AmountToSection.tsx
new file mode 100644
index 0000000000..4a09d14a33
--- /dev/null
+++ b/src/problem2/src/components/form/AmountToSection.tsx
@@ -0,0 +1,94 @@
+import { Box, Field, Flex, Text } from "@chakra-ui/react";
+import type React from "react";
+import { COMMON_UI_STYLES } from "../../consts";
+import { Select, OutputReadout } from "../../ui";
+import { useFormContext } from "react-hook-form";
+import type { TFormValues } from "../../models";
+import { transformData } from "../../utils";
+import { useSwapUI } from "../../hooks/useSwapUi";
+
+export const AmountToSection: React.FC = () => {
+ const { watch, setValue } = useFormContext();
+ const data = watch("data") ?? [];
+ const selectList = transformData(data);
+ const from = watch("from") as string;
+ const to = watch("to") as string;
+ const amountIn = watch("amountIn") as string;
+
+ const { toPriceHint, outputAmount, pairRate } = useSwapUI(
+ data,
+ from,
+ to,
+ amountIn
+ );
+
+ return (
+
+
+
+
+ You receive
+
+
+ {toPriceHint}
+
+
+
+
+
+ {outputAmount}
+
+
+
+ {pairRate}
+
+
+
+
+ );
+};
diff --git a/src/problem2/src/components/form/index.tsx b/src/problem2/src/components/form/index.tsx
new file mode 100644
index 0000000000..3e16c65bff
--- /dev/null
+++ b/src/problem2/src/components/form/index.tsx
@@ -0,0 +1,95 @@
+import { Box, Text } from "@chakra-ui/react";
+import React, { useCallback } from "react";
+import { useFormContext, type SubmitHandler } from "react-hook-form";
+import type { TFormValues } from "../../models";
+import { Button, toaster } from "../../ui";
+import { FlipArrowsIcon } from "../../ui/icons";
+import { AmountFromSection } from "./AmountFromSection";
+import { AmountToSection } from "./AmountToSection";
+import { formatAmount } from "../../utils";
+import { useSwapUI } from "../../hooks/useSwapUi";
+
+export const Form: React.FC = () => {
+ const { watch, handleSubmit, setValues, setValue, clearErrors, resetField } =
+ useFormContext();
+ const from = watch("from") as string;
+ const to = watch("to") as string;
+ const data = watch("data");
+ const amountIn = watch("amountIn");
+ const isSubmitting = watch("isSubmitting");
+ const { rate } = useSwapUI(data, from, to, amountIn);
+
+ const onSubmit: SubmitHandler = async (data) => {
+ setValue("isSubmitting", true);
+ const recv =
+ rate !== null && amountIn !== ""
+ ? Number(amountIn) * Number(rate)
+ : null;
+ const recvStr = recv !== null ? formatAmount(recv) : "—";
+ setTimeout(() => {
+ toaster.create({
+ type: "info",
+ title: "Swap submitted",
+ closable: true,
+ description: `${formatAmount(Number(data.amountIn) ?? 0)} ${data.from} → ${recvStr} ${data.to}`,
+ });
+ resetField("amountIn");
+ setValue("isSubmitting", false);
+ }, 2000);
+ };
+
+ const onFlip = useCallback(() => {
+ setValues({ from: to, to: from });
+ clearErrors(["amountIn"]);
+ }, [clearErrors, from, setValues, to]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/problem2/src/consts.ts b/src/problem2/src/consts.ts
new file mode 100644
index 0000000000..12fe20a40f
--- /dev/null
+++ b/src/problem2/src/consts.ts
@@ -0,0 +1,43 @@
+///
+
+/** Augment Vite env for optional app overrides. */
+interface ImportMetaEnv {
+ readonly VITE_PRICES_URL?: string;
+ readonly VITE_TOKEN_ICONS_BASE_URL?: string;
+}
+
+function viteEnvString(key: keyof ImportMetaEnv): string | undefined {
+ const v = import.meta.env[key];
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
+}
+
+/** Token price JSON. Override with `VITE_PRICES_URL` in `src/problem2/with-react/.env`. */
+export const PRICES_URL =
+ viteEnvString("VITE_PRICES_URL") ??
+ "https://interview.switcheo.com/prices.json";
+
+/**
+ * Base URL for token SVGs (no trailing slash). Override with `VITE_TOKEN_ICONS_BASE_URL`
+ * in `src/problem2/with-react/.env`.
+ */
+export const TOKEN_ICONS_BASE_URL =
+ viteEnvString("VITE_TOKEN_ICONS_BASE_URL")?.replace(/\/+$/, "") ??
+ "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens";
+
+export const COMMON_UI_STYLES = {
+ focusWithinRing: {
+ borderColor: "#9333ea",
+ boxShadow: "0 0 0 3px rgba(147, 51, 234, 0.28)",
+ },
+ gridCss: {
+ display: "grid",
+ minHeight: 0,
+ gridTemplateColumns: "1fr",
+ gap: "0.65rem",
+ alignItems: "stretch",
+ "& > div": { minHeight: 0 },
+ "@media (min-width: 421px)": {
+ gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1.15fr)",
+ },
+ },
+};
diff --git a/src/problem2/src/hooks/useFetch.ts b/src/problem2/src/hooks/useFetch.ts
new file mode 100644
index 0000000000..8ded31d628
--- /dev/null
+++ b/src/problem2/src/hooks/useFetch.ts
@@ -0,0 +1,36 @@
+import { useState } from "react";
+export type FetchMethods = "GET" | "POST" | "PUT" | "DELETE";
+
+export type FetchOptions = {
+ method: FetchMethods;
+ headers: Record;
+ body: string;
+};
+
+export function useFetch(): {
+ data: Type | null;
+ loading: boolean;
+ error: Error | null;
+ invoke: (url: string, options?: FetchOptions) => void;
+} {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const invoke = (url: string, options?: FetchOptions) => {
+ setLoading(true);
+ setError(null);
+ fetch(url, options)
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error("Failed to fetch data");
+ }
+ return response.json();
+ })
+ .then((data) => setData(data))
+ .catch((error) => setError(error))
+ .finally(() => setLoading(false));
+ };
+
+ return { data, loading, error, invoke };
+}
diff --git a/src/problem2/src/hooks/useSwapUi.ts b/src/problem2/src/hooks/useSwapUi.ts
new file mode 100644
index 0000000000..17f2e386ac
--- /dev/null
+++ b/src/problem2/src/hooks/useSwapUi.ts
@@ -0,0 +1,52 @@
+import type { TPrice } from "../models";
+import { formatAmount, formatUsd, rate } from "../utils";
+
+export const useSwapUI = (
+ data: Array<
+ Omit & {
+ date: number;
+ }
+ >,
+ from: string,
+ to: string,
+ rawAmount: string
+) => {
+ const prices = new Map(
+ data
+ .filter((x) => [from, to].includes(x.currency))
+ .map((x) => [x.currency, x.price])
+ );
+ const pf = prices.get(from);
+ const pt = prices.get(to);
+ const fromPriceHint = !!pf ? `Ref ${formatUsd(pf)}` : "";
+ const toPriceHint = !!pt ? `Ref ${formatUsd(pt)}` : "";
+
+ if (from === to) {
+ return {
+ fromPriceHint,
+ toPriceHint,
+ outputAmount: "—",
+ rate: null,
+ pairRate: "",
+ };
+ }
+
+ const r = rate(from, to, prices);
+ if (r === null) {
+ return {
+ fromPriceHint,
+ toPriceHint,
+ outputAmount: "—",
+ rate: null,
+ pairRate: "",
+ };
+ }
+
+ return {
+ fromPriceHint,
+ toPriceHint,
+ outputAmount: rawAmount !== "" ? formatAmount(Number(rawAmount) * r) : "—",
+ rate: r,
+ pairRate: `1 ${from} ≈ ${formatAmount(r)} ${to}`,
+ };
+};
diff --git a/src/problem2/src/main.tsx b/src/problem2/src/main.tsx
new file mode 100644
index 0000000000..2103c666e2
--- /dev/null
+++ b/src/problem2/src/main.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+import { Toaster, UIProvider } from "./ui";
+
+const el = document.getElementById("root");
+if (!el) throw new Error("Missing #root");
+
+createRoot(el).render(
+
+
+
+
+
+
+);
diff --git a/src/problem2/src/models.ts b/src/problem2/src/models.ts
new file mode 100644
index 0000000000..552054416b
--- /dev/null
+++ b/src/problem2/src/models.ts
@@ -0,0 +1,48 @@
+export type TCurrency =
+ | "BLUR"
+ | "bNEO"
+ | "BUSD"
+ | "USD"
+ | "ETH"
+ | "GMX"
+ | "STEVMOS"
+ | "LUNA"
+ | "RATOM"
+ | "STRD"
+ | "EVMOS"
+ | "IBCX"
+ | "IRIS"
+ | "ampLUNA"
+ | "KUJI"
+ | "STOSMO"
+ | "USDC"
+ | "axlUSDC"
+ | "ATOM"
+ | "STATOM"
+ | "OSMO"
+ | "rSWTH"
+ | "STLUNA"
+ | "LSI"
+ | "OKB"
+ | "OKT"
+ | "SWTH"
+ | "USC"
+ | "WBTC"
+ | "wstETH"
+ | "YieldUSD"
+ | "ZIL"
+ | (string & {});
+
+export type TPrice = {
+ currency: TCurrency;
+ date: string;
+ price: number;
+};
+
+export type TFormValues = {
+ from?: string;
+ to?: string;
+ amountIn: string;
+ data: Array & { date: number }>;
+ isSubmitting: boolean;
+};
diff --git a/src/problem2/src/ui/button.tsx b/src/problem2/src/ui/button.tsx
new file mode 100644
index 0000000000..85a524a0bd
--- /dev/null
+++ b/src/problem2/src/ui/button.tsx
@@ -0,0 +1,176 @@
+import { Button as ChakraButton } from "@chakra-ui/react";
+import { type ComponentPropsWithoutRef, type ReactNode } from "react";
+
+export type ButtonVariant = "primary" | "secondary" | "flip";
+
+export type ButtonProps = Omit<
+ ComponentPropsWithoutRef,
+ "variant" | "loading"
+> & {
+ variant?: ButtonVariant;
+ loading?: boolean;
+ children?: ReactNode;
+};
+
+const sansFont = `"DM Sans", system-ui, sans-serif`;
+
+const primaryCss = {
+ width: "100%",
+ fontFamily: sansFont,
+ border: "1px solid rgba(0, 0, 0, 0.06)",
+ borderRadius: "14px",
+ background: "linear-gradient(135deg, #c084fc 0%, #9333ea 52%, #7c22ce 100%)",
+ color: "#ffffff",
+ fontWeight: 600,
+ letterSpacing: "0.02em",
+ padding: "0.95rem 1.25rem",
+ fontSize: "0.95rem",
+ boxShadow: "0 10px 32px rgba(147, 51, 234, 0.35)",
+ transition:
+ "opacity 0.2s ease, transform 0.22s cubic-bezier(0.34, 1.45, 0.64, 1), box-shadow 0.25s ease, filter 0.25s ease",
+ _hover: {
+ transform: "translateY(-3px) scale(1.015)",
+ filter: "brightness(1.06)",
+ boxShadow:
+ "0 16px 40px rgba(147, 51, 234, 0.42), 0 6px 16px rgba(124, 34, 206, 0.25)",
+ },
+ _active: {
+ transform: "translateY(0) scale(0.98)",
+ filter: "brightness(0.97)",
+ boxShadow:
+ "0 6px 18px rgba(147, 51, 234, 0.28), 0 2px 6px rgba(0, 0, 0, 0.1)",
+ transitionDuration: "0.1s",
+ },
+ _focusVisible: {
+ outline: "2px solid #7c22ce",
+ outlineOffset: "3px",
+ },
+ _disabled: {
+ opacity: 0.55,
+ cursor: "not-allowed",
+ transform: "none",
+ filter: "none",
+ },
+ _motionReduce: {
+ transition: "none",
+ _hover: { transform: "none", filter: "none" },
+ _active: { transform: "none" },
+ },
+} as const;
+
+const secondaryCss = {
+ fontFamily: sansFont,
+ borderRadius: "14px",
+ border: "1px solid rgba(147, 51, 234, 0.22)",
+ background: "#ffffff",
+ color: "#000000",
+ fontWeight: 600,
+ letterSpacing: "0.02em",
+ padding: "0.95rem 1.25rem",
+ fontSize: "0.95rem",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "0.5rem",
+ transition:
+ "opacity 0.2s ease, transform 0.22s cubic-bezier(0.34, 1.45, 0.64, 1), box-shadow 0.25s ease, filter 0.25s ease",
+ _hover: {
+ borderColor: "#9333ea",
+ background: "rgba(192, 132, 252, 0.1)",
+ },
+ _focusVisible: {
+ outline: "2px solid #7c22ce",
+ outlineOffset: "3px",
+ },
+ _disabled: {
+ opacity: 0.55,
+ cursor: "not-allowed",
+ },
+ _motionReduce: { transition: "none" },
+} as const;
+
+const flipCss = {
+ display: "grid",
+ width: "44px",
+ height: "44px",
+ flexShrink: 0,
+ placeItems: "center",
+ borderRadius: "14px",
+ border: "1px solid rgba(147, 51, 234, 0.22)",
+ background: "linear-gradient(180deg, #ffffff, #faf5ff)",
+ color: "#9333ea",
+ boxShadow:
+ "0 4px 14px rgba(147, 51, 234, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06)",
+ transition:
+ "transform 0.28s cubic-bezier(0.34, 1.45, 0.64, 1), box-shadow 0.28s ease, border-color 0.2s ease, color 0.2s ease, background 0.25s ease",
+ "& svg": {
+ transformOrigin: "center",
+ transition: "transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1)",
+ },
+ _hover: {
+ transform: "translateY(-0.5px) scale(1.07)",
+ borderColor: "#9333ea",
+ background: "linear-gradient(180deg, #fdf4ff, #f5e8ff)",
+ color: "#7c22ce",
+ boxShadow:
+ "0 14px 32px rgba(147, 51, 234, 0.28), 0 6px 14px rgba(0, 0, 0, 0.08)",
+ "& svg": { transform: "rotate(180deg)" },
+ },
+ _active: {
+ transform: "translateY(0) scale(0.94)",
+ boxShadow:
+ "0 4px 12px rgba(147, 51, 234, 0.18), 0 2px 4px rgba(0, 0, 0, 0.06)",
+ transitionDuration: "0.1s",
+ "& svg": {
+ transform: "rotate(180deg) scale(0.9)",
+ transitionDuration: "0.12s",
+ },
+ },
+ _focusVisible: {
+ outline: "2px solid #9333ea",
+ outlineOffset: "3px",
+ },
+ _motionReduce: {
+ transition: "none",
+ "& svg": { transition: "none" },
+ _hover: { transform: "none", "& svg": { transform: "none" } },
+ _active: { transform: "none", "& svg": { transform: "none" } },
+ },
+} as const;
+
+export function Button({
+ variant = "secondary",
+ loading = false,
+ disabled,
+ children,
+ type,
+ "aria-busy": ariaBusy,
+ css: cssProp,
+ ...rest
+}: ButtonProps) {
+ const isPrimary = variant === "primary";
+ const busy = loading || ariaBusy;
+ const isDisabled = Boolean(disabled) || (isPrimary && Boolean(loading));
+
+ const variantCss =
+ variant === "primary"
+ ? primaryCss
+ : variant === "flip"
+ ? flipCss
+ : secondaryCss;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/problem2/src/ui/icons/flipArrows.tsx b/src/problem2/src/ui/icons/flipArrows.tsx
new file mode 100644
index 0000000000..2ae83323b4
--- /dev/null
+++ b/src/problem2/src/ui/icons/flipArrows.tsx
@@ -0,0 +1,18 @@
+export function FlipArrowsIcon() {
+ return (
+
+ );
+}
diff --git a/src/problem2/src/ui/icons/index.tsx b/src/problem2/src/ui/icons/index.tsx
new file mode 100644
index 0000000000..56ae37650c
--- /dev/null
+++ b/src/problem2/src/ui/icons/index.tsx
@@ -0,0 +1 @@
+export { FlipArrowsIcon } from "./flipArrows";
diff --git a/src/problem2/src/ui/index.tsx b/src/problem2/src/ui/index.tsx
new file mode 100644
index 0000000000..01699df93f
--- /dev/null
+++ b/src/problem2/src/ui/index.tsx
@@ -0,0 +1,6 @@
+export { type ButtonVariant, type ButtonProps, Button } from "./button";
+export { type NumberInputProps, NumberInput } from "./numberInput";
+export { UIProvider } from "./provider";
+export { Select } from "./select";
+export { toaster, Toaster } from "./toaster";
+export { OutputReadout } from "./ouputReadout";
diff --git a/src/problem2/src/ui/numberInput.tsx b/src/problem2/src/ui/numberInput.tsx
new file mode 100644
index 0000000000..1d9ddd94ac
--- /dev/null
+++ b/src/problem2/src/ui/numberInput.tsx
@@ -0,0 +1,60 @@
+import { Input } from "@chakra-ui/react";
+import React from "react";
+import { type InputHTMLAttributes } from "react";
+
+export type NumberInputProps = Omit<
+ InputHTMLAttributes,
+ "size" | "color"
+>;
+
+export const NumberInput: React.FC = (props) => {
+ const handleKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ const target = e.target as HTMLInputElement;
+ const { value, selectionStart, selectionEnd } = target;
+
+ if (e.metaKey || e.ctrlKey) return;
+
+ if (e.key.length > 1) return;
+
+ const futureValue =
+ value.substring(0, selectionStart ?? 0) +
+ e.key +
+ value.substring(selectionEnd ?? 0);
+
+ const pattern = /^[0-9]*(\.[0-9]*)?$/;
+
+ if (!pattern.test(futureValue)) {
+ e.preventDefault();
+ }
+ },
+ []
+ );
+
+ return (
+
+ );
+};
diff --git a/src/problem2/src/ui/ouputReadout.tsx b/src/problem2/src/ui/ouputReadout.tsx
new file mode 100644
index 0000000000..2d9b7578ac
--- /dev/null
+++ b/src/problem2/src/ui/ouputReadout.tsx
@@ -0,0 +1,46 @@
+import { Box } from "@chakra-ui/react";
+
+export const OutputReadout: React.FC = ({
+ children,
+}) => (
+
+
+ {children}
+
+
+);
diff --git a/src/problem2/src/ui/provider.tsx b/src/problem2/src/ui/provider.tsx
new file mode 100644
index 0000000000..8cefd65c03
--- /dev/null
+++ b/src/problem2/src/ui/provider.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import {
+ ChakraProvider,
+ createSystem,
+ defaultConfig,
+ defineConfig,
+} from "@chakra-ui/react";
+
+const dmSans = '"DM Sans", system-ui, sans-serif';
+const jetBrainsMono = '"JetBrains Mono", ui-monospace, monospace';
+
+const swapPalette = {
+ brand: "#9333ea",
+ brandDeep: "#7c22ce",
+ brandSoft: "#c084fc",
+ brandSoftBg: "rgba(192, 132, 252, 0.18)",
+ brandRing: "rgba(147, 51, 234, 0.28)",
+ ink: "#000000",
+ inkSubtle: "#404040",
+ inkMuted: "#737373",
+ surface: "#ffffff",
+ surfaceElevated: "#fafafa",
+ surfaceTint: "rgba(192, 132, 252, 0.1)",
+ line: "rgba(0, 0, 0, 0.08)",
+ lineStrong: "rgba(147, 51, 234, 0.22)",
+ danger: "#dc2626",
+ dangerBg: "rgba(220, 38, 38, 0.08)",
+} as const;
+
+const swapColors = Object.fromEntries(
+ Object.entries(swapPalette).map(([k, v]) => [k, { value: v }])
+) as Record;
+
+/**
+ * Full scale for `colorPalette="brand"` on Chakra components (buttons, badges, etc.).
+ * Anchored to the same purples as `swapPalette` / pure-js.
+ */
+const brandScale = {
+ 50: { value: "#faf5ff" },
+ 100: { value: "#f3e8ff" },
+ 200: { value: "#e9d5ff" },
+ 300: { value: "#d8b4fe" },
+ 400: { value: "#c084fc" },
+ 500: { value: "#a855f7" },
+ 600: { value: "#9333ea" },
+ 700: { value: "#7c22ce" },
+ 800: { value: "#5b1899" },
+ 900: { value: "#3d0f6b" },
+ 950: { value: "#200838" },
+} as const;
+
+/**
+ * Chakra design system for the swap UI: fonts + 99TECH brand colors (pure-js parity).
+ */
+export const swapSystem = createSystem(
+ defaultConfig,
+ defineConfig({
+ theme: {
+ tokens: {
+ fonts: {
+ body: { value: dmSans },
+ heading: { value: dmSans },
+ mono: { value: jetBrainsMono },
+ },
+ colors: {
+ swap: swapColors,
+ brand: brandScale,
+ },
+ radii: {
+ "swap-lg": { value: "20px" },
+ "swap-md": { value: "14px" },
+ "swap-def": { value: "12px" },
+ },
+ borders: {
+ "swap-def": { value: "1px solid rgba(0, 0, 0, 0.08)" },
+ "swap-strong": { value: "1px solid rgba(147, 51, 234, 0.22)" },
+ },
+ shadows: {
+ "swap-lg": {
+ value:
+ "0 24px 48px -12px rgba(147, 51, 234, 0.18), 0 8px 16px rgba(0, 0, 0, 0.06)",
+ },
+ },
+ },
+ },
+ globalCss: {
+ html: {
+ colorScheme: "light",
+ },
+ body: {
+ fontFamily: "body",
+ fontOpticalSizing: "auto",
+ color: "swap.ink",
+ },
+ },
+ })
+);
+
+export const UIProvider: React.FC = ({ children }) => {
+ return {children};
+};
diff --git a/src/problem2/src/ui/select.tsx b/src/problem2/src/ui/select.tsx
new file mode 100644
index 0000000000..567961de33
--- /dev/null
+++ b/src/problem2/src/ui/select.tsx
@@ -0,0 +1,91 @@
+import {
+ Avatar,
+ HStack,
+ Select as ChakraSelect,
+ useSelectContext,
+ type ListCollection,
+} from "@chakra-ui/react";
+
+const SelectValue = () => {
+ const select = useSelectContext();
+ const items = select.selectedItems as Array<{ name: string; avatar: string }>;
+ const { name, avatar } = items?.[0] ?? [];
+ return (
+
+
+
+
+
+
+ {name}
+
+
+ );
+};
+
+type TCollectionItem = {
+ id: string;
+ value: string;
+ name: string;
+ avatar: string;
+};
+
+interface IProps {
+ collection: ListCollection;
+ defaultValue?: string[];
+ value?: string[];
+ onChange: (val: string[]) => void;
+}
+
+export const Select: React.FC = ({
+ collection,
+ value,
+ onChange,
+ defaultValue,
+}) => (
+ onChange(e.value)}
+ borderRadius="swap-def"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {collection.items.map((item) => (
+
+
+
+
+
+ {item.name}
+
+
+ ))}
+
+
+
+);
diff --git a/src/problem2/src/ui/toaster.tsx b/src/problem2/src/ui/toaster.tsx
new file mode 100644
index 0000000000..5d70a35bba
--- /dev/null
+++ b/src/problem2/src/ui/toaster.tsx
@@ -0,0 +1,43 @@
+"use client"
+
+import {
+ Toaster as ChakraToaster,
+ Portal,
+ Spinner,
+ Stack,
+ Toast,
+ createToaster,
+} from "@chakra-ui/react"
+
+export const toaster = createToaster({
+ placement: "bottom-end",
+ pauseOnPageIdle: true,
+})
+
+export const Toaster = () => {
+ return (
+
+
+ {(toast) => (
+
+ {toast.type === "loading" ? (
+
+ ) : (
+
+ )}
+
+ {toast.title && {toast.title}}
+ {toast.description && (
+ {toast.description}
+ )}
+
+ {toast.action && (
+ {toast.action.label}
+ )}
+ {toast.closable && }
+
+ )}
+
+
+ )
+}
diff --git a/src/problem2/src/utils.ts b/src/problem2/src/utils.ts
new file mode 100644
index 0000000000..3adf8e5a4d
--- /dev/null
+++ b/src/problem2/src/utils.ts
@@ -0,0 +1,49 @@
+import { createListCollection } from "@chakra-ui/react";
+import type { TPrice } from "./models";
+import { TOKEN_ICONS_BASE_URL } from "./consts";
+
+export function tokenIconUrl(currency: string): string {
+ const safe = encodeURIComponent(currency);
+ return `${TOKEN_ICONS_BASE_URL}/${safe}.svg`;
+}
+export const transformData = (
+ data: Array & { date: number }>
+) =>
+ createListCollection({
+ items: data.map((x, i) => ({
+ id: x.currency,
+ value: x.currency,
+ name: x.currency,
+ avatar: tokenIconUrl(x.currency),
+ })),
+ });
+
+export function formatAmount(n: number): string {
+ const abs = Math.abs(n);
+ const digits = abs >= 1 ? 6 : abs >= 0.0001 ? 8 : 10;
+ return n.toLocaleString("en-US", {
+ maximumFractionDigits: digits,
+ minimumFractionDigits: 0,
+ });
+}
+export function formatUsd(n: number): string {
+ if (n >= 1000)
+ return `$${n.toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
+ if (n >= 1)
+ return `$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
+ if (n >= 0.0001)
+ return `$${n.toLocaleString("en-US", { maximumFractionDigits: 6 })}`;
+ return `$${n.toExponential(2)}`;
+}
+
+/** Price ratio: how many `to` tokens per one `from`. */
+export function rate(
+ from: string,
+ to: string,
+ prices: Map
+): number | null {
+ const pf = prices.get(from);
+ const pt = prices.get(to);
+ if (!pf || !Boolean(pt)) return null;
+ return pf / (pt as number);
+}
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/vite-env.d.ts b/src/problem2/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/src/problem2/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/src/problem2/vite.config.ts b/src/problem2/vite.config.ts
new file mode 100644
index 0000000000..70e9c4c479
--- /dev/null
+++ b/src/problem2/vite.config.ts
@@ -0,0 +1,22 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+const root = path.dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig({
+ plugins: [react()],
+ root,
+ /** Relative asset URLs so `dist` works from any path (file://, subfolder on a server). */
+ base: "./",
+ publicDir: false,
+ build: {
+ outDir: path.resolve(root, "../../dist/problem2-with-react"),
+ emptyOutDir: true,
+ },
+ server: {
+ port: 5174,
+ open: true,
+ },
+});
diff --git a/src/problem3/README.md b/src/problem3/README.md
new file mode 100644
index 0000000000..49b002b251
--- /dev/null
+++ b/src/problem3/README.md
@@ -0,0 +1,385 @@
+# Problem 3: Messy React — Code Review & Refactoring
+
+This document lists the issues found in the original snippet and how this solution addresses them. The refactor lives under `src/problem3/` (`models.ts`, `consts.ts`, `walletRowPipeline.ts`, `hooks.ts`, `page.tsx`, `WalletRow.tsx`).
+
+## Identified Issues
+
+---
+
+### 1. **`undefined` variable `lhsPriority` (Bug / Runtime Error)**
+
+Inside the `filter` callback, the code references `lhsPriority`, which is **never declared**. The value computed above is `balancePriority`. That throws a `ReferenceError` at runtime.
+
+```ts
+// ❌ Bug: lhsPriority is undefined, should be balancePriority
+const balancePriority = getPriority(balance.blockchain);
+if (lhsPriority > -99) { ... }
+
+// ✅ This solution: priority is passed explicitly to a small predicate
+function isEligibleRow(priority: number, amount: number): boolean {
+ return priority > UNKNOWN_PRIORITY && amount > 0;
+}
+```
+
+---
+
+### 2. **Incorrect Filter Logic (Logic Error)**
+
+The filter is meant to keep balances with a supported chain **and** a positive amount. Returning `true` when `balance.amount <= 0` **keeps** zero/negative balances.
+
+```ts
+// ❌ Wrong: keeps balances with amount <= 0
+if (balance.amount <= 0) {
+ return true;
+}
+
+// ✅ Correct: keep valid, positive balances (see isEligibleRow above)
+```
+
+---
+
+### 3. **`getPriority` Defined Inside the Component (Performance Anti-pattern)**
+
+`getPriority` is pure and does not depend on props/state. Defining it inside the component recreates the function every render. It belongs at **module scope**, ideally backed by a lookup for O(1) reads.
+
+```ts
+// ❌ Re-created on every render
+const WalletPage = (props) => {
+ const getPriority = (blockchain: any): number => { ... };
+};
+
+// ✅ Module-level map + helper (`consts.ts` + `walletRowPipeline.ts`)
+export const PRIORITY_BY_CHAIN: Readonly>> = {
+ Osmosis: 100,
+ Ethereum: 50,
+ Arbitrum: 30,
+ Zilliqa: 20,
+ Neo: 20,
+};
+
+export function getPriority(chain: Blockchain): number {
+ return PRIORITY_BY_CHAIN[chain] ?? UNKNOWN_PRIORITY;
+}
+```
+
+---
+
+### 4. **`blockchain` Typed as `any` (TypeScript Anti-pattern)**
+
+`blockchain: any` removes compile-time checks. A dedicated union (with room for unknown strings if needed) is used instead.
+
+```ts
+// ❌ No type safety
+const getPriority = (blockchain: any): number => { ... };
+
+// ✅ `models.ts`
+export type Blockchain =
+ | 'Osmosis'
+ | 'Ethereum'
+ | 'Arbitrum'
+ | 'Zilliqa'
+ | 'Neo'
+ | (string & {});
+```
+
+---
+
+### 5. **`prices` in `useMemo` Dependencies but Unused — and Misplaced Computation**
+
+`prices` was listed in `useMemo` deps while the memo only filtered/sorted, so the dependency was misleading and `usdValue` risked being computed outside the memo in a way that did not line up with memoization.
+
+```ts
+// ❌ prices in deps but unused inside memo
+const sortedBalances = useMemo(() => {
+ return balances.filter(...).sort(...);
+}, [balances, prices]);
+
+// ✅ This solution: all derivation that uses `prices` runs in one place.
+// `buildWalletRowViewModels(balances, prices)` applies prices inside the pipeline;
+// `useMemo` in `page.tsx` depends on `[balances, prices]` and calls that builder.
+```
+
+---
+
+### 6. **`formattedBalances` Computed but Never Used (Dead Code / Bug)**
+
+`formattedBalances` was built from `sortedBalances`, but `rows` iterated **`sortedBalances`**, not the formatted array—so `formatted` was never on the objects passed to `WalletRow`.
+
+```ts
+// ❌ Second map used the wrong array → formattedAmount effectively undefined
+const formattedBalances = sortedBalances.map(...);
+const rows = sortedBalances.map(...);
+
+// ✅ Single pipeline: filter → sort → map to `WalletRowViewModel` (formatted + usdValue)
+export const buildWalletRowViewModels = (
+ balances: readonly WalletBalance[],
+ prices: Readonly>,
+): WalletRowViewModel[] =>
+ balances
+ .filter((balance) => isEligibleRow(getPriority(balance.blockchain), balance.amount))
+ .toSorted((a, b) => getPriority(b.blockchain) - getPriority(a.blockchain))
+ .map((balance) => toWalletRowViewModel(balance, prices));
+```
+
+---
+
+### 7. **Using Array Index as `key` (React Anti-pattern)**
+
+`key={index}` mis-associates DOM/state when the list order or length changes. A **stable business key** is preferred when unique.
+
+```tsx
+// ❌ Index is unstable
+
+
+// ✅ Composite key (`page.tsx`)
+
+```
+
+> If the product can show two rows with the same chain and currency, add a stable `id` from the API when available.
+
+---
+
+### 8. **`blockchain` Field Missing from `WalletBalance` Interface (TypeScript Gap)**
+
+The original interface listed only `currency` and `amount`, but `balance.blockchain` was used everywhere.
+
+```ts
+// ✅ `models.ts`
+export interface WalletBalance {
+ blockchain: Blockchain;
+ currency: string;
+ amount: number;
+}
+```
+
+---
+
+### 9. **`sort` Comparator Missing Return for Equal Priorities (Edge Case / Bug)**
+
+When priorities were equal, the comparator could fall through without returning a number, yielding **undefined** and implementation-defined ordering.
+
+```ts
+// ❌ No return when priorities are equal
+
+// ✅ Numeric comparator always returns a number (`walletRowPipeline.ts`)
+.toSorted((a, b) => getPriority(b.blockchain) - getPriority(a.blockchain));
+```
+
+---
+
+### 10. **`children` Destructured but Never Rendered (Silent Data Loss)**
+
+The original pulled `children` out of props but only rendered `rows`, so any nested content was dropped.
+
+```tsx
+// ❌ Original: children never rendered
+
+// ✅ This refactor: `WalletPage` is a thin shell; it renders `{rows}` inside ``.
+// Callers that need both custom layout and rows should either compose at a parent level or
+// extend this component to render `props.children` explicitly next to `rows`.
+```
+
+---
+
+### 11. **`.sort()` Mutates the Array (Immutability)**
+
+`Array.prototype.sort` sorts in place. Mutating arrays owned by hooks or parents can cause subtle bugs.
+
+```ts
+// ❌ In-place sort on a shared or aliased array is risky
+
+// ✅ Use a non-mutating sort (`walletRowPipeline.ts`)
+// Requires `lib` including ES2023 Array (see repo `tsconfig.json`).
+.toSorted((a, b) => getPriority(b.blockchain) - getPriority(a.blockchain));
+```
+
+---
+
+### 12. **`prices[currency]` May Be `undefined` → `NaN` (Safety)**
+
+Multiplying `undefined * amount` yields `NaN`, which can propagate into the UI.
+
+```ts
+// ❌ NaN if currency not in prices
+const usdValue = prices[balance.currency] * balance.amount;
+
+// ✅ (`toWalletRowViewModel` in `walletRowPipeline.ts`)
+const unitUsd = prices[balance.currency] ?? 0;
+return { ...balance, formatted: balance.amount.toFixed(), usdValue: unitUsd * balance.amount };
+```
+
+---
+
+### 13. **`amount.toFixed()` — Decimal Precision (Code Quality)**
+
+`toFixed()` with no argument uses **0** decimal places. For money-like values, an explicit precision (e.g. `toFixed(2)`) is usually clearer.
+
+```ts
+// Current implementation (matches original behavior, easy to tighten later)
+formatted: balance.amount.toFixed();
+
+// Optional improvement
+formatted: balance.amount.toFixed(2);
+```
+
+---
+
+## Summary of Issues
+
+| # | Issue | Category |
+|---|-------|----------|
+| 1 | `lhsPriority` undefined — `ReferenceError` at runtime | Bug |
+| 2 | Filter logic inverted (`<= 0` kept bad balances) | Logic error |
+| 3 | `getPriority` recreated every render | Performance |
+| 4 | `blockchain: any` — no type safety | TypeScript |
+| 5 | `prices` dep mismatch / `usdValue` not aligned with memo | Performance / logic |
+| 6 | `formattedBalances` unused; `formatted` wrong | Bug / dead code |
+| 7 | Array index as React `key` | React anti-pattern |
+| 8 | `blockchain` missing from `WalletBalance` | TypeScript gap |
+| 9 | `sort` returned `undefined` for equal priorities | Edge case |
+| 10 | `children` not rendered (original) | Silent data loss |
+| 11 | `.sort()` mutates in place | Immutability |
+| 12 | Missing price → `NaN` | Safety |
+| 13 | `toFixed()` precision implicit | Code quality |
+
+---
+
+## Refactored Version (This Repo)
+
+Design choices in this submission:
+
+1. **Pure pipeline** — `buildWalletRowViewModels` in `walletRowPipeline.ts` has no React imports, so filter/sort/format/USD logic is **unit-testable** and reused from a single `useMemo` in `page.tsx`.
+2. **Stable module APIs** — priorities and `getPriority` live beside the model types instead of inside the component.
+3. **Immutability** — `toSorted` avoids mutating the filtered array (ES2023).
+
+### `models.ts` (types)
+
+```ts
+export type Blockchain =
+ | 'Osmosis'
+ | 'Ethereum'
+ | 'Arbitrum'
+ | 'Zilliqa'
+ | 'Neo'
+ | (string & {});
+
+export interface WalletBalance {
+ blockchain: Blockchain;
+ currency: string;
+ amount: number;
+}
+
+export interface FormattedWalletBalance extends WalletBalance {
+ formatted: string;
+}
+
+export interface WalletRowViewModel extends FormattedWalletBalance {
+ usdValue: number;
+}
+
+export type BoxProps = ComponentPropsWithoutRef<'div'>;
+```
+
+### `consts.ts` (priority table)
+
+```ts
+export const PRIORITY_BY_CHAIN: Readonly
>> = {
+ Osmosis: 100,
+ Ethereum: 50,
+ Arbitrum: 30,
+ Zilliqa: 20,
+ Neo: 20,
+};
+
+export const UNKNOWN_PRIORITY = -99;
+```
+
+### `walletRowPipeline.ts` (pure builder)
+
+```ts
+export function getPriority(chain: Blockchain): number {
+ return PRIORITY_BY_CHAIN[chain] ?? UNKNOWN_PRIORITY;
+}
+
+function isEligibleRow(priority: number, amount: number): boolean {
+ return priority > UNKNOWN_PRIORITY && amount > 0;
+}
+
+function toWalletRowViewModel(
+ balance: WalletBalance,
+ prices: Readonly>,
+): WalletRowViewModel {
+ const unitUsd = prices[balance.currency] ?? 0;
+ return {
+ ...balance,
+ formatted: balance.amount.toFixed(),
+ usdValue: unitUsd * balance.amount,
+ };
+}
+
+export const buildWalletRowViewModels = (
+ balances: readonly WalletBalance[],
+ prices: Readonly>,
+): WalletRowViewModel[] =>
+ balances
+ .filter((balance) => isEligibleRow(getPriority(balance.blockchain), balance.amount))
+ .toSorted((a, b) => getPriority(b.blockchain) - getPriority(a.blockchain))
+ .map((balance) => toWalletRowViewModel(balance, prices));
+```
+
+### `page.tsx` (React boundary)
+
+```tsx
+export function WalletPage(props: BoxProps) {
+ const balances = useWalletBalances();
+ const prices = usePrices();
+
+ const rows = useMemo((): ReactElement[] => {
+ const viewModels = buildWalletRowViewModels(balances, prices);
+ return viewModels.map((row) => (
+
+ ));
+ }, [balances, prices]);
+
+ return {rows}
;
+}
+```
+
+### `WalletRow.tsx` (presentational row)
+
+```tsx
+export function WalletRow({
+ className,
+ amount,
+ usdValue,
+ formattedAmount,
+}: WalletRowProps) {
+ return (
+
+ {formattedAmount}
+
+ );
+}
+```
+
+### `hooks.ts` (stubs)
+
+`useWalletBalances` and `usePrices` are placeholders for the real data layer (context, React Query, etc.).
+
+---
+
+## File map
+
+| File | Role |
+|------|------|
+| `models.ts` | `Blockchain`, `WalletBalance`, `WalletRowViewModel`, `BoxProps` |
+| `consts.ts` | `PRIORITY_BY_CHAIN`, `UNKNOWN_PRIORITY` |
+| `walletRowPipeline.ts` | `getPriority`, `buildWalletRowViewModels` |
+| `hooks.ts` | Data hooks (stubs) |
+| `page.tsx` | `WalletPage` + `useMemo` wiring |
+| `WalletRow.tsx` | Row UI |
diff --git a/src/problem3/WalletRow.tsx b/src/problem3/WalletRow.tsx
new file mode 100644
index 0000000000..aed19b07c6
--- /dev/null
+++ b/src/problem3/WalletRow.tsx
@@ -0,0 +1,19 @@
+export interface WalletRowProps {
+ className?: string;
+ amount: number;
+ usdValue: number;
+ formattedAmount: string;
+}
+
+export function WalletRow({
+ className,
+ amount,
+ usdValue,
+ formattedAmount,
+}: WalletRowProps) {
+ return (
+
+ {formattedAmount}
+
+ );
+}
diff --git a/src/problem3/consts.ts b/src/problem3/consts.ts
new file mode 100644
index 0000000000..54c24845af
--- /dev/null
+++ b/src/problem3/consts.ts
@@ -0,0 +1,11 @@
+import type { Blockchain } from './models';
+
+export const PRIORITY_BY_CHAIN: Readonly>> = {
+ Osmosis: 100,
+ Ethereum: 50,
+ Arbitrum: 30,
+ Zilliqa: 20,
+ Neo: 20,
+};
+
+export const UNKNOWN_PRIORITY = -99;
diff --git a/src/problem3/hooks.ts b/src/problem3/hooks.ts
new file mode 100644
index 0000000000..853ce0eaf0
--- /dev/null
+++ b/src/problem3/hooks.ts
@@ -0,0 +1,11 @@
+import type { WalletBalance } from './models';
+
+/** Replace with your app’s data layer (context, React Query, etc.). */
+export function useWalletBalances(): WalletBalance[] {
+ return [];
+}
+
+/** Replace with your price feed / store. */
+export function usePrices(): Readonly> {
+ return {};
+}
diff --git a/src/problem3/models.ts b/src/problem3/models.ts
new file mode 100644
index 0000000000..1dc5ea235f
--- /dev/null
+++ b/src/problem3/models.ts
@@ -0,0 +1,26 @@
+import type { ComponentPropsWithoutRef } from 'react';
+
+export type Blockchain =
+ | 'Osmosis'
+ | 'Ethereum'
+ | 'Arbitrum'
+ | 'Zilliqa'
+ | 'Neo'
+ | (string & {});
+
+export interface WalletBalance {
+ blockchain: Blockchain;
+ currency: string;
+ amount: number;
+}
+
+export interface FormattedWalletBalance extends WalletBalance {
+ formatted: string;
+}
+
+/** Row-ready data: formatted amount + derived USD for one balance. */
+export interface WalletRowViewModel extends FormattedWalletBalance {
+ usdValue: number;
+}
+
+export type BoxProps = ComponentPropsWithoutRef<'div'>;
diff --git a/src/problem3/page.tsx b/src/problem3/page.tsx
new file mode 100644
index 0000000000..e7ece8f19c
--- /dev/null
+++ b/src/problem3/page.tsx
@@ -0,0 +1,25 @@
+import { useMemo, type ReactElement } from 'react';
+import { usePrices, useWalletBalances } from './hooks';
+import type { BoxProps } from './models';
+import { WalletRow } from './WalletRow';
+import { buildWalletRowViewModels } from './walletRowPipeline';
+
+export function WalletPage(props: BoxProps) {
+ const balances = useWalletBalances();
+ const prices = usePrices();
+
+ const rows = useMemo((): ReactElement[] => {
+ const viewModels = buildWalletRowViewModels(balances, prices);
+ return viewModels.map((row) => (
+
+ ));
+ }, [balances, prices]);
+
+ return {rows}
;
+}
diff --git a/src/problem3/walletRowPipeline.ts b/src/problem3/walletRowPipeline.ts
new file mode 100644
index 0000000000..b6f0060ca4
--- /dev/null
+++ b/src/problem3/walletRowPipeline.ts
@@ -0,0 +1,36 @@
+import { UNKNOWN_PRIORITY, PRIORITY_BY_CHAIN } from './consts';
+import type { Blockchain, WalletBalance, WalletRowViewModel } from './models';
+
+
+export function getPriority(chain: Blockchain): number {
+ return PRIORITY_BY_CHAIN[chain] ?? UNKNOWN_PRIORITY;
+}
+
+function isEligibleRow(priority: number, amount: number): boolean {
+ return priority > UNKNOWN_PRIORITY && amount > 0;
+}
+
+function toWalletRowViewModel(
+ balance: WalletBalance,
+ prices: Readonly>,
+): WalletRowViewModel {
+ const unitUsd = prices[balance.currency] ?? 0;
+ return {
+ ...balance,
+ formatted: balance.amount.toFixed(),
+ usdValue: unitUsd * balance.amount,
+ };
+}
+
+/**
+ * Pure pipeline: filter by chain priority + positive amount, sort by priority,
+ * attach formatted amount and USD. Keeps React out so this stays easy to unit test.
+ */
+export const buildWalletRowViewModels = (
+ balances: readonly WalletBalance[],
+ prices: Readonly>,
+): WalletRowViewModel[] => balances
+ .filter((balance) => isEligibleRow(getPriority(balance.blockchain), balance.amount))
+ .toSorted((a, b) => getPriority(b.blockchain) - getPriority(a.blockchain) )
+ .map((balance ) => toWalletRowViewModel(balance, prices));
+
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000000..fad34b0e79
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "ES2023.Array", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "vitest.config.ts"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000000..8940065fb2
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ globals: false,
+ include: ["src/**/*.test.ts"],
+ },
+});