From 71607385fa367b6242e7ffbe44c890625cda39a3 Mon Sep 17 00:00:00 2001 From: NePreby Date: Fri, 6 Nov 2026 12:10:05 +0700 Subject: [PATCH 01/10] feat(manifest): parse Cargo.lock and package-lock.json for transitive dependencies (closes #6) --- frontend/src/lib/__tests__/manifest.test.ts | 250 +++++++++++++++++- frontend/src/lib/manifest.ts | 272 +++++++++++++++++++- 2 files changed, 513 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/__tests__/manifest.test.ts b/frontend/src/lib/__tests__/manifest.test.ts index bc26051..aa7827a 100644 --- a/frontend/src/lib/__tests__/manifest.test.ts +++ b/frontend/src/lib/__tests__/manifest.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from "vitest"; -import { equalWeights, parseManifest } from "../manifest"; +import { + equalWeights, + MAX_SPLIT_ROWS, + parseCargoLock, + parseCargoToml, + parseManifest, + parsePackageJson, + parsePackageLockJson, +} from "../manifest"; describe("parseCargoToml", () => { it("reads all common dependency forms", () => { - const m = parseManifest(` + const raw = ` [package] name = "demo" @@ -21,7 +29,8 @@ proptest = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" # comment -`); +`; + const m = parseCargoToml(raw); expect(m.name).toBe("demo"); const byName = Object.fromEntries(m.deps.map((d) => [d.name, d])); expect(byName.serde.version).toBe("1.0"); @@ -36,17 +45,246 @@ libc = "0.2" # comment describe("parsePackageJson", () => { it("reads deps, peer and dev deps without duplicates", () => { - const m = parseManifest( - JSON.stringify({ name: "x", dependencies: { a: "^1" }, peerDependencies: { b: "2" }, devDependencies: { a: "1", c: "3" } }), - ); + const raw = JSON.stringify({ + name: "x", + dependencies: { a: "^1" }, + peerDependencies: { b: "2" }, + devDependencies: { a: "1", c: "3" }, + }); + const m = parsePackageJson(raw); expect(m.ecosystem).toBe("npm"); expect(m.deps.map((d) => `${d.name}:${d.kind}`)).toEqual(["a:normal", "b:peer", "c:dev"]); }); + it("errors on invalid json", () => { expect(() => parseManifest("{ nope", "package.json")).toThrow(/valid JSON/); }); }); +describe("parseCargoLock", () => { + const sampleCargoLock = ` +version = 3 + +[[package]] +name = "my-crate" +version = "0.1.0" +dependencies = [ + "serde", + "soroban-sdk 22.0.1", +] + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "soroban-sdk" +version = "22.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +`; + + it("parses direct dependencies only by default", () => { + const m = parseCargoLock(sampleCargoLock, { includeTransitive: false }); + expect(m.ecosystem).toBe("cargo"); + expect(m.name).toBe("my-crate"); + expect(m.deps.map((d) => d.name)).toEqual(["serde", "soroban-sdk"]); + expect(m.deps.find((d) => d.name === "serde")?.version).toBe("1.0.197"); + }); + + it("parses transitive dependencies when requested", () => { + const m = parseCargoLock(sampleCargoLock, { includeTransitive: true }); + expect(m.ecosystem).toBe("cargo"); + const names = m.deps.map((d) => d.name); + expect(names).toContain("serde"); + expect(names).toContain("serde_derive"); + expect(names).toContain("proc-macro2"); + expect(names).toContain("quote"); + expect(names).toContain("syn"); + expect(names).not.toContain("my-crate"); + }); + + it("caps results at MAX_SPLIT_ROWS (32) and de-duplicates", () => { + let bigLock = ` +[[package]] +name = "root" +version = "0.1.0" +dependencies = [] +`; + for (let i = 0; i < 50; i++) { + bigLock += ` +[[package]] +name = "dep-${i}" +version = "1.0.${i}" +source = "registry+https://..." +`; + } + // Duplicate one + bigLock += ` +[[package]] +name = "dep-0" +version = "1.0.99" +source = "registry+https://..." +`; + + const m = parseCargoLock(bigLock, { includeTransitive: true }); + expect(m.deps.length).toBe(MAX_SPLIT_ROWS); + expect(m.deps.length).toBeLessThanOrEqual(32); + const seen = new Set(m.deps.map((d) => d.name)); + expect(seen.size).toBe(m.deps.length); + }); + + it("routes through parseManifest with filename detection", () => { + const m = parseManifest(sampleCargoLock, "Cargo.lock", { includeTransitive: true }); + expect(m.ecosystem).toBe("cargo"); + expect(m.deps.length).toBeGreaterThan(2); + }); +}); + +describe("parsePackageLockJson", () => { + const sampleV3Lock = JSON.stringify({ + name: "flowpulse-frontend", + version: "0.1.0", + lockfileVersion: 3, + packages: { + "": { + name: "flowpulse-frontend", + version: "0.1.0", + dependencies: { + "@stellar/stellar-sdk": "^17.4.0", + react: "^19.3.1", + }, + devDependencies: { + typescript: "~5.9.3", + }, + }, + "node_modules/@stellar/stellar-sdk": { + version: "17.4.0", + dependencies: { + axios: "^1.7.0", + eventsource: "^2.0.2", + }, + }, + "node_modules/axios": { + version: "1.7.9", + }, + "node_modules/eventsource": { + version: "2.0.2", + }, + "node_modules/react": { + version: "19.3.1", + }, + "node_modules/typescript": { + version: "5.9.3", + dev: true, + }, + }, + }); + + it("extracts direct dependencies from v3 lockfile when includeTransitive is false", () => { + const m = parsePackageLockJson(sampleV3Lock, { includeTransitive: false }); + expect(m.ecosystem).toBe("npm"); + expect(m.name).toBe("flowpulse-frontend"); + const names = m.deps.map((d) => d.name); + expect(names).toEqual(["@stellar/stellar-sdk", "react", "typescript"]); + const ts = m.deps.find((d) => d.name === "typescript"); + expect(ts?.kind).toBe("dev"); + }); + + it("extracts transitive dependencies from v3 lockfile when includeTransitive is true", () => { + const m = parsePackageLockJson(sampleV3Lock, { includeTransitive: true }); + expect(m.ecosystem).toBe("npm"); + const names = m.deps.map((d) => d.name); + expect(names).toContain("@stellar/stellar-sdk"); + expect(names).toContain("axios"); + expect(names).toContain("eventsource"); + expect(names).toContain("react"); + expect(names).toContain("typescript"); + expect(names).not.toContain("flowpulse-frontend"); + }); + + it("extracts transitive dependencies from v1 lockfile", () => { + const sampleV1Lock = JSON.stringify({ + name: "v1-app", + version: "1.0.0", + lockfileVersion: 1, + dependencies: { + express: { + version: "4.19.2", + dependencies: { + accepts: { + version: "1.3.8", + dependencies: { + "mime-types": { + version: "2.1.35", + }, + }, + }, + }, + }, + }, + }); + + const direct = parsePackageLockJson(sampleV1Lock, { includeTransitive: false }); + expect(direct.deps.map((d) => d.name)).toEqual(["express"]); + + const transitive = parsePackageLockJson(sampleV1Lock, { includeTransitive: true }); + expect(transitive.deps.map((d) => d.name)).toEqual(["express", "accepts", "mime-types"]); + }); + + it("caps at maxRows and handles nested node_modules paths correctly", () => { + const sampleNested = JSON.stringify({ + name: "nested-app", + lockfileVersion: 2, + packages: { + "": { name: "nested-app" }, + "node_modules/a": { version: "1.0.0" }, + "node_modules/a/node_modules/b": { version: "2.0.0" }, + "node_modules/@scope/pkg": { version: "3.0.0" }, + }, + }); + + const m = parsePackageLockJson(sampleNested, { includeTransitive: true, maxRows: 2 }); + expect(m.deps.length).toBe(2); + expect(m.deps[0].name).toBe("a"); + expect(m.deps[1].name).toBe("b"); + }); + + it("routes through parseManifest with package-lock.json", () => { + const m = parseManifest(sampleV3Lock, "package-lock.json", { includeTransitive: true }); + expect(m.ecosystem).toBe("npm"); + expect(m.deps.map((d) => d.name)).toContain("axios"); + }); +}); + describe("equalWeights", () => { it("sums exactly to the target", () => { const w = equalWeights(7, 9000); diff --git a/frontend/src/lib/manifest.ts b/frontend/src/lib/manifest.ts index 306d01f..b4e4d68 100644 --- a/frontend/src/lib/manifest.ts +++ b/frontend/src/lib/manifest.ts @@ -1,8 +1,10 @@ /** * Dependency manifest parsing for split tables. - * Supports Cargo.toml, package.json, and Cargo.lock / package-lock.json (top-level only). + * Supports Cargo.toml, package.json, Cargo.lock, and package-lock.json (with transitive dependencies option). */ +export const MAX_SPLIT_ROWS = 32; + export type Ecosystem = "cargo" | "npm"; export interface Dependency { @@ -18,9 +20,34 @@ export interface Manifest { deps: Dependency[]; } -export function parseManifest(text: string, filename = ""): Manifest { +export interface ParseManifestOptions { + includeTransitive?: boolean; + maxRows?: number; +} + +export function parseManifest(text: string, filename = "", options?: ParseManifestOptions): Manifest { const t = text.trim(); - if (filename.endsWith(".json") || t.startsWith("{")) return parsePackageJson(t); + const lowerFile = filename.toLowerCase(); + + if (lowerFile.endsWith("cargo.lock") || (lowerFile.endsWith(".lock") && t.includes("[[package]]"))) { + return parseCargoLock(t, options); + } + + if ( + lowerFile.endsWith("package-lock.json") || + (lowerFile.endsWith(".json") && t.includes('"lockfileVersion"')) + ) { + return parsePackageLockJson(t, options); + } + + if (lowerFile.endsWith(".json") || t.startsWith("{")) { + return parsePackageJson(t); + } + + if (t.includes("[[package]]") && !t.includes("[package]") && !t.includes("[workspace]")) { + return parseCargoLock(t, options); + } + return parseCargoToml(t); } @@ -50,6 +77,245 @@ export function parsePackageJson(text: string): Manifest { return { ecosystem: "npm", name: typeof j.name === "string" ? j.name : undefined, deps }; } +/** + * Parses Cargo.lock and optionally includes transitive dependencies. + * De-duplicates and caps at maxRows (default MAX_SPLIT_ROWS = 32). + */ +export function parseCargoLock(text: string, options?: ParseManifestOptions): Manifest { + const includeTransitive = options?.includeTransitive ?? false; + const maxRows = options?.maxRows ?? MAX_SPLIT_ROWS; + + interface RawPackage { + name: string; + version: string; + source?: string; + dependencies: string[]; + } + + const packages: RawPackage[] = []; + let current: Partial | null = null; + let inDependencies = false; + + for (const raw of text.split(/\r?\n/)) { + const line = raw.replace(/\s+#.*$/, "").trim(); + if (!line || line.startsWith("#")) continue; + + if (line === "[[package]]") { + if (current && current.name && current.version) { + packages.push({ + name: current.name, + version: current.version, + source: current.source, + dependencies: current.dependencies ?? [], + }); + } + current = { dependencies: [] }; + inDependencies = false; + continue; + } + + if (!current) continue; + + if (inDependencies) { + if (line === "]") { + inDependencies = false; + continue; + } + const depMatch = line.match(/^"([^"]+)"/); + if (depMatch) { + const depRaw = depMatch[1].trim(); + const depName = depRaw.split(" ")[0]; + current.dependencies!.push(depName); + } + continue; + } + + const kv = line.match(/^("?)([A-Za-z0-9_\-]+)\1\s*=\s*(.+)$/); + if (!kv) continue; + const key = kv[2]; + const val = kv[3].trim(); + + if (key === "name") { + current.name = unquote(val); + } else if (key === "version") { + current.version = unquote(val); + } else if (key === "source") { + current.source = unquote(val); + } else if (key === "dependencies") { + if (val === "[") { + inDependencies = true; + } else if (val.startsWith("[") && val.endsWith("]")) { + const inner = val.slice(1, -1).trim(); + if (inner) { + const list = inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean); + current.dependencies = list.map((d) => d.split(" ")[0]); + } + } + } + } + + if (current && current.name && current.version) { + packages.push({ + name: current.name, + version: current.version, + source: current.source, + dependencies: current.dependencies ?? [], + }); + } + + if (packages.length === 0) { + return { ecosystem: "cargo", deps: [] }; + } + + // Root packages are local packages without external source specification + const rootPackages = packages.filter((p) => !p.source); + const primaryRoot = rootPackages[0] ?? packages[0]; + const rootNames = new Set(rootPackages.map((p) => p.name)); + + const deps: Dependency[] = []; + const seen = new Set(); + + const pushDep = (name: string, version: string) => { + if (seen.has(name) || rootNames.has(name)) return; + seen.add(name); + deps.push({ + name, + version, + ecosystem: "cargo", + kind: "normal", + }); + }; + + if (!includeTransitive) { + // Only direct dependencies of the root packages + const directDeps = new Set(); + for (const r of rootPackages) { + for (const d of r.dependencies) { + directDeps.add(d); + } + } + for (const pkg of packages) { + if (directDeps.has(pkg.name)) { + pushDep(pkg.name, pkg.version); + if (deps.length >= maxRows) break; + } + } + } else { + // All unique transitive dependencies across the entire lock graph + for (const pkg of packages) { + pushDep(pkg.name, pkg.version); + if (deps.length >= maxRows) break; + } + } + + return { + ecosystem: "cargo", + name: primaryRoot?.name, + deps: deps.slice(0, maxRows), + }; +} + +/** + * Parses package-lock.json (v1, v2, v3 formats) and optionally includes transitive dependencies. + * De-duplicates and caps at maxRows (default MAX_SPLIT_ROWS = 32). + */ +export function parsePackageLockJson(text: string, options?: ParseManifestOptions): Manifest { + const includeTransitive = options?.includeTransitive ?? false; + const maxRows = options?.maxRows ?? MAX_SPLIT_ROWS; + + let j: Record; + try { + j = JSON.parse(text); + } catch { + throw new Error("package-lock.json is not valid JSON"); + } + + const rootName = typeof j.name === "string" ? j.name : undefined; + const deps: Dependency[] = []; + const seen = new Set(); + + const pushDep = (name: string, version: string, kind: Dependency["kind"] = "normal") => { + if (!name || seen.has(name) || name === rootName) return; + seen.add(name); + deps.push({ + name, + version: String(version || "*"), + ecosystem: "npm", + kind, + }); + }; + + // Modern npm lockfile format: v2 / v3 using "packages" + if (j.packages && typeof j.packages === "object") { + const pkgs = j.packages as Record>; + const rootPkg = pkgs[""] || {}; + const directDeps = (rootPkg.dependencies || {}) as Record; + const directDevDeps = (rootPkg.devDependencies || {}) as Record; + const directPeerDeps = (rootPkg.peerDependencies || {}) as Record; + + if (!includeTransitive) { + for (const [name, reqVer] of Object.entries(directDeps)) { + const resolved = pkgs[`node_modules/${name}`]?.version; + pushDep(name, String(resolved || reqVer), "normal"); + if (deps.length >= maxRows) break; + } + for (const [name, reqVer] of Object.entries(directPeerDeps)) { + if (deps.length >= maxRows) break; + const resolved = pkgs[`node_modules/${name}`]?.version; + pushDep(name, String(resolved || reqVer), "peer"); + } + for (const [name, reqVer] of Object.entries(directDevDeps)) { + if (deps.length >= maxRows) break; + const resolved = pkgs[`node_modules/${name}`]?.version; + pushDep(name, String(resolved || reqVer), "dev"); + } + } else { + // Collect all packages from node_modules/* + for (const [pathKey, val] of Object.entries(pkgs)) { + if (!pathKey || pathKey === "" || !val || typeof val !== "object") continue; + const lastIndex = pathKey.lastIndexOf("node_modules/"); + if (lastIndex === -1) continue; + const rawPkgName = pathKey.slice(lastIndex + "node_modules/".length); + if (!rawPkgName) continue; + + const isDev = Boolean(val.dev); + const isPeer = Boolean(val.peer); + const kind: Dependency["kind"] = isPeer ? "peer" : isDev ? "dev" : "normal"; + const ver = typeof val.version === "string" ? val.version : "*"; + + pushDep(rawPkgName, ver, kind); + if (deps.length >= maxRows) break; + } + } + } else if (j.dependencies && typeof j.dependencies === "object") { + // Legacy npm lockfile format: v1 + const depsTree = j.dependencies as Record>; + + const walkV1 = (tree: Record>) => { + for (const [name, node] of Object.entries(tree)) { + if (!node || typeof node !== "object") continue; + const ver = typeof node.version === "string" ? node.version : "*"; + const isDev = Boolean(node.dev); + pushDep(name, ver, isDev ? "dev" : "normal"); + if (deps.length >= maxRows) return; + + if (includeTransitive && node.dependencies && typeof node.dependencies === "object") { + walkV1(node.dependencies as Record>); + if (deps.length >= maxRows) return; + } + } + }; + + walkV1(depsTree); + } + + return { + ecosystem: "npm", + name: rootName, + deps: deps.slice(0, maxRows), + }; +} + /** * Minimal TOML reader covering the dependency tables used in practice: * [dependencies] foo = "1" | foo = { version = "1", ... } | foo.workspace = true From 1f8907d0432cba94925733fc943e9ea803dfbae1 Mon Sep 17 00:00:00 2001 From: NePreby Date: Fri, 6 Nov 2026 13:14:59 +0700 Subject: [PATCH 02/10] feat(ui): trap focus in sheets and restore focus on close (closes #14) --- frontend/src/components/ui.tsx | 53 ++++++++++- frontend/src/lib/__tests__/focus.test.ts | 116 +++++++++++++++++++++++ frontend/src/lib/focus.ts | 53 +++++++++++ 3 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/__tests__/focus.test.ts create mode 100644 frontend/src/lib/focus.ts diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 67d182a..0dbeee4 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState, useRef, type ReactNode } from "react"; +import { handleFocusTrap, FOCUSABLE_SELECTOR } from "../lib/focus"; export function Icon({ name, className = "", fill = false }: { name: string; className?: string; fill?: boolean }) { return ( @@ -60,20 +61,66 @@ export function Sheet({ children: ReactNode; wide?: boolean; }) { + const triggerRef = useRef(null); + const dialogRef = useRef(null); + useEffect(() => { if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + + // Capture the trigger element before opening + triggerRef.current = document.activeElement as HTMLElement | null; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + + if (dialogRef.current) { + handleFocusTrap({ + container: dialogRef.current, + event: e, + activeElement: document.activeElement, + }); + } + }; + window.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; + + // Focus into dialog on open + const timer = setTimeout(() => { + if (dialogRef.current) { + const focusables = dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusables.length > 0) { + focusables[0].focus(); + } else { + dialogRef.current.focus(); + } + } + }, 0); + return () => { + clearTimeout(timer); window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; + // Restore focus to trigger element on close + triggerRef.current?.focus(); }; }, [open, onClose]); + if (!open) return null; + return ( -
+
- + View on explorer
diff --git a/frontend/src/lib/__tests__/qr.test.ts b/frontend/src/lib/__tests__/qr.test.ts new file mode 100644 index 0000000..fe5cc53 --- /dev/null +++ b/frontend/src/lib/__tests__/qr.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { encodeQr, qrToSvgPath } from "../qr"; + +describe("QR Code Generator", () => { + it("encodes a basic URL into a valid QR matrix", () => { + const url = "https://stellar.expert/explorer/testnet/tx/1234567890abcdef"; + const qr = encodeQr(url); + + expect(qr.size).toBeGreaterThanOrEqual(21); + expect(qr.modules.length).toBe(qr.size); + expect(qr.modules[0].length).toBe(qr.size); + + // Verify top-left finder pattern (7x7 outer boundary has black modules) + expect(qr.modules[0][0]).toBe(true); + expect(qr.modules[0][6]).toBe(true); + expect(qr.modules[6][0]).toBe(true); + expect(qr.modules[6][6]).toBe(true); + expect(qr.modules[1][1]).toBe(false); // inner white space + expect(qr.modules[3][3]).toBe(true); // center black module + }); + + it("generates an SVG path string from the matrix", () => { + const qr = encodeQr("https://flowpulse.org"); + const path = qrToSvgPath(qr); + + expect(typeof path).toBe("string"); + expect(path.startsWith("M")).toBe(true); + expect(path).toContain("h1v1h-1z"); + }); + + it("handles empty or short strings without crashing", () => { + const qr = encodeQr("test"); + expect(qr.size).toBe(21); // Version 1 size: 17 + 4*1 = 21 + }); +}); diff --git a/frontend/src/lib/qr.ts b/frontend/src/lib/qr.ts new file mode 100644 index 0000000..533c8c8 --- /dev/null +++ b/frontend/src/lib/qr.ts @@ -0,0 +1,284 @@ +/** + * Lightweight, zero-dependency QR Code (Model 2) SVG generator. + * Implements Byte mode with standard Reed-Solomon error correction (Level M/L). + */ + +// Galois Field GF(256) tables with primitive polynomial 0x11d (285) +const EXP_TABLE = new Uint8Array(512); +const LOG_TABLE = new Uint8Array(256); + +(function initGaloisField() { + let x = 1; + for (let i = 0; i < 255; i++) { + EXP_TABLE[i] = x; + EXP_TABLE[i + 255] = x; + LOG_TABLE[x] = i; + x <<= 1; + if (x & 0x100) x ^= 0x11d; + } +})(); + +function gfMul(x: number, y: number): number { + if (x === 0 || y === 0) return 0; + return EXP_TABLE[LOG_TABLE[x] + LOG_TABLE[y]]; +} + +// Generator polynomials for Reed-Solomon codes +function rsGeneratorPoly(degree: number): Uint8Array { + let poly = new Uint8Array([1]); + for (let i = 0; i < degree; i++) { + const next = new Uint8Array(poly.length + 1); + for (let j = 0; j < poly.length; j++) { + next[j] ^= gfMul(poly[j], EXP_TABLE[i]); + next[j + 1] ^= poly[j]; + } + poly = next; + } + return poly; +} + +function rsComputeRemainder(data: Uint8Array, ecLength: number): Uint8Array { + const gen = rsGeneratorPoly(ecLength); + const res = new Uint8Array(ecLength); + for (const byte of data) { + const factor = byte ^ res[0]; + res.copyWithin(0, 1); + res[ecLength - 1] = 0; + for (let i = 0; i < ecLength; i++) { + res[i] ^= gfMul(gen[i], factor); + } + } + return res; +} + +// Version table capacities for Byte Mode (Level L) +// Version 1..10 capacities and EC parameters +interface QrVersionSpec { + version: number; + totalCodewords: number; + ecCodewords: number; + dataCodewords: number; + blocks: number; + alignment: number[]; +} + +const VERSION_SPECS: QrVersionSpec[] = [ + { version: 1, totalCodewords: 26, ecCodewords: 7, dataCodewords: 19, blocks: 1, alignment: [] }, + { version: 2, totalCodewords: 44, ecCodewords: 10, dataCodewords: 34, blocks: 1, alignment: [6, 18] }, + { version: 3, totalCodewords: 70, ecCodewords: 15, dataCodewords: 55, blocks: 1, alignment: [6, 22] }, + { version: 4, totalCodewords: 100, ecCodewords: 20, dataCodewords: 80, blocks: 1, alignment: [6, 26] }, + { version: 5, totalCodewords: 134, ecCodewords: 26, dataCodewords: 108, blocks: 1, alignment: [6, 30] }, + { version: 6, totalCodewords: 172, ecCodewords: 36, dataCodewords: 136, blocks: 2, alignment: [6, 34] }, + { version: 7, totalCodewords: 196, ecCodewords: 40, dataCodewords: 156, blocks: 2, alignment: [6, 22, 38] }, + { version: 8, totalCodewords: 242, ecCodewords: 48, dataCodewords: 194, blocks: 2, alignment: [6, 24, 42] }, + { version: 9, totalCodewords: 292, ecCodewords: 60, dataCodewords: 232, blocks: 2, alignment: [6, 26, 46] }, + { version: 10, totalCodewords: 346, ecCodewords: 72, dataCodewords: 274, blocks: 4, alignment: [6, 28, 50] }, +]; + +export interface QrMatrix { + size: number; + modules: boolean[][]; +} + +/** + * Encodes text into a QR code boolean matrix (Version 1-10, EC Level L). + */ +export function encodeQr(text: string): QrMatrix { + const utf8 = new TextEncoder().encode(text); + const len = utf8.length; + + // Find lowest suitable version + const spec = VERSION_SPECS.find((s) => s.dataCodewords >= len + 3); + if (!spec) { + throw new Error(`Text too long for supported QR versions (max ~270 bytes)`); + } + + // 1. Bitstream assembly: Mode indicator (0100 for Byte mode) + character count + data + const bits: number[] = []; + function pushBits(val: number, count: number) { + for (let i = count - 1; i >= 0; i--) { + bits.push((val >>> i) & 1); + } + } + + pushBits(0b0100, 4); // Byte mode indicator + pushBits(len, spec.version < 10 ? 8 : 16); // Character count indicator + for (const byte of utf8) { + pushBits(byte, 8); + } + + // Terminator (up to 4 zeroes) + const maxBits = spec.dataCodewords * 8; + const termLen = Math.min(4, maxBits - bits.length); + pushBits(0, termLen); + + // Pad to multiple of 8 + while (bits.length % 8 !== 0) { + bits.push(0); + } + + // Pad bytes (0xEC, 0x11 alternating) + const dataBytes = new Uint8Array(spec.dataCodewords); + for (let i = 0; i < bits.length / 8; i++) { + let b = 0; + for (let j = 0; j < 8; j++) { + b = (b << 1) | bits[i * 8 + j]; + } + dataBytes[i] = b; + } + + const pad = [0xec, 0x11]; + let padIdx = 0; + for (let i = bits.length / 8; i < spec.dataCodewords; i++) { + dataBytes[i] = pad[padIdx % 2]; + padIdx++; + } + + // 2. Reed-Solomon Error Correction + const ecLengthPerBlock = Math.floor(spec.ecCodewords / spec.blocks); + const dataLengthPerBlock = Math.floor(spec.dataCodewords / spec.blocks); + const allEcBlocks: Uint8Array[] = []; + const allDataBlocks: Uint8Array[] = []; + + for (let b = 0; b < spec.blocks; b++) { + const start = b * dataLengthPerBlock; + const blockData = dataBytes.slice(start, start + dataLengthPerBlock); + allDataBlocks.push(blockData); + allEcBlocks.push(rsComputeRemainder(blockData, ecLengthPerBlock)); + } + + // Interleave data and EC codewords + const finalCodewords: number[] = []; + for (let i = 0; i < dataLengthPerBlock; i++) { + for (let b = 0; b < spec.blocks; b++) { + finalCodewords.push(allDataBlocks[b][i]); + } + } + for (let i = 0; i < ecLengthPerBlock; i++) { + for (let b = 0; b < spec.blocks; b++) { + finalCodewords.push(allEcBlocks[b][i]); + } + } + + // 3. Matrix construction + const size = 17 + 4 * spec.version; + const matrix: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)); + const isFunction: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)); + + function setFunctionModule(r: number, c: number, val: boolean) { + matrix[r][c] = val; + isFunction[r][c] = true; + } + + // Finder patterns at (0,0), (size-7, 0), (0, size-7) + function drawFinderPattern(row: number, col: number) { + for (let r = -1; r <= 7; r++) { + for (let c = -1; c <= 7; c++) { + const nr = row + r; + const nc = col + c; + if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue; + const isBlack = + (r >= 0 && r <= 6 && (c === 0 || c === 6)) || + (c >= 0 && c <= 6 && (r === 0 || r === 6)) || + (r >= 2 && r <= 4 && c >= 2 && c <= 4); + setFunctionModule(nr, nc, isBlack); + } + } + } + + drawFinderPattern(0, 0); + drawFinderPattern(0, size - 7); + drawFinderPattern(size - 7, 0); + + // Alignment patterns for version >= 2 + const coords = spec.alignment; + for (let i = 0; i < coords.length; i++) { + for (let j = 0; j < coords.length; j++) { + const r = coords[i]; + const c = coords[j]; + if (isFunction[r][c]) continue; + for (let dr = -2; dr <= 2; dr++) { + for (let dc = -2; dc <= 2; dc++) { + const isBlack = Math.max(Math.abs(dr), Math.abs(dc)) !== 1; + setFunctionModule(r + dr, c + dc, isBlack); + } + } + } + } + + // Timing patterns + for (let i = 8; i < size - 8; i++) { + if (!isFunction[6][i]) setFunctionModule(6, i, i % 2 === 0); + if (!isFunction[i][6]) setFunctionModule(i, 6, i % 2 === 0); + } + + // Dark module + setFunctionModule(4 * spec.version + 9, 8, true); + + // Format info reserve + for (let i = 0; i <= 8; i++) { + if (!isFunction[8][i]) isFunction[8][i] = true; + if (!isFunction[i][8]) isFunction[i][8] = true; + } + for (let i = size - 8; i < size; i++) { + if (!isFunction[8][i]) isFunction[8][i] = true; + if (!isFunction[i][8]) isFunction[i][8] = true; + } + + // 4. Place data bits with Mask 0 ((row + col) % 2 === 0) + const bitArray: number[] = []; + for (const byte of finalCodewords) { + for (let i = 7; i >= 0; i--) { + bitArray.push((byte >>> i) & 1); + } + } + + let bitIdx = 0; + let upwards = true; + for (let right = size - 1; right > 0; right -= 2) { + if (right === 6) right--; // Skip vertical timing column + const rows = upwards + ? Array.from({ length: size }, (_, i) => size - 1 - i) + : Array.from({ length: size }, (_, i) => i); + + for (const r of rows) { + for (const c of [right, right - 1]) { + if (!isFunction[r][c]) { + const bit = bitIdx < bitArray.length ? bitArray[bitIdx++] : 0; + const mask = (r + c) % 2 === 0; + matrix[r][c] = (bit ^ (mask ? 1 : 0)) === 1; + } + } + } + upwards = !upwards; + } + + // 5. Write Format Information (Level L, Mask 0: bits 111011111000100) + const formatBits = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0]; + for (let i = 0; i < 6; i++) matrix[8][i] = formatBits[i] === 1; + matrix[8][7] = formatBits[6] === 1; + matrix[8][8] = formatBits[7] === 1; + matrix[7][8] = formatBits[8] === 1; + for (let i = 9; i < 15; i++) matrix[14 - i][8] = formatBits[i] === 1; + + for (let i = 0; i < 8; i++) matrix[size - 1 - i][8] = formatBits[i] === 1; + for (let i = 8; i < 15; i++) matrix[8][size - 15 + i] = formatBits[i] === 1; + + return { size, modules: matrix }; +} + +/** + * Generates an SVG path data string for rendering the QR matrix cleanly. + */ +export function qrToSvgPath(matrix: QrMatrix): string { + const parts: string[] = []; + const { size, modules } = matrix; + for (let r = 0; r < size; r++) { + for (let c = 0; c < size; c++) { + if (modules[r][c]) { + parts.push(`M${c},${r}h1v1h-1z`); + } + } + } + return parts.join(""); +} From d1b986da6d3a58178ae3f1bfd7bc541bc282d860 Mon Sep 17 00:00:00 2001 From: NePreby Date: Fri, 6 Nov 2026 16:10:18 +0700 Subject: [PATCH 07/10] feat(manifest): resolve maintainer Stellar addresses from FUNDING.json (closes #4) --- .../lib/__tests__/manifest_funding.test.ts | 144 ++++++++++++++++++ frontend/src/lib/manifest.ts | 134 ++++++++++++++++ frontend/src/pages/Cascades.tsx | 57 ++++++- 3 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/__tests__/manifest_funding.test.ts diff --git a/frontend/src/lib/__tests__/manifest_funding.test.ts b/frontend/src/lib/__tests__/manifest_funding.test.ts new file mode 100644 index 0000000..4a05504 --- /dev/null +++ b/frontend/src/lib/__tests__/manifest_funding.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + extractStellarAddressFromFundingJson, + extractGitHubRepoSlug, + resolveMaintainerFundingAddress, +} from "../manifest"; + +describe("FUNDING.json Stellar address extraction", () => { + it("extracts direct stellar field", () => { + const raw = JSON.stringify({ + stellar: "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI", + }); + expect(extractStellarAddressFromFundingJson(raw)).toBe( + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI" + ); + }); + + it("extracts from accounts array", () => { + const raw = JSON.stringify({ + accounts: [ + { network: "ethereum", address: "0x1234567890abcdef" }, + { network: "stellar", address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" }, + ], + }); + expect(extractStellarAddressFromFundingJson(raw)).toBe( + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + ); + }); + + it("extracts from drips extension", () => { + const raw = JSON.stringify({ + drips: { + stellar: "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI", + }, + }); + expect(extractStellarAddressFromFundingJson(raw)).toBe( + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI" + ); + }); + + it("returns null for invalid or missing Stellar addresses", () => { + expect(extractStellarAddressFromFundingJson('{"ethereum": "0x123"}')).toBeNull(); + expect(extractStellarAddressFromFundingJson("invalid json")).toBeNull(); + }); + + it("extracts GitHub repo slug from various URL formats", () => { + expect(extractGitHubRepoSlug("https://github.com/stellar/rs-soroban-sdk")).toBe("stellar/rs-soroban-sdk"); + expect(extractGitHubRepoSlug("https://github.com/facebook/react.git")).toBe("facebook/react"); + expect(extractGitHubRepoSlug("git+https://github.com/serde-rs/json.git")).toBe("serde-rs/json"); + expect(extractGitHubRepoSlug("ssh://git@github.com/flow-pulse/contracts.git")).toBe("flow-pulse/contracts"); + expect(extractGitHubRepoSlug("https://gitlab.com/other/repo")).toBeNull(); + }); +}); + +describe("resolveMaintainerFundingAddress with mocked fetch", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("resolves address for Cargo dependency via crates.io and FUNDING.json", async () => { + const mockStellar = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + + globalThis.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = url.toString(); + if (u.includes("crates.io/api/v1/crates/soroban-sdk")) { + return { + ok: true, + json: async () => ({ + crate: { repository: "https://github.com/stellar/rs-soroban-sdk" }, + }), + } as unknown as Response; + } + if (u.includes("raw.githubusercontent.com/stellar/rs-soroban-sdk/HEAD/.github/FUNDING.json")) { + return { + ok: true, + text: async () => JSON.stringify({ stellar: mockStellar }), + } as unknown as Response; + } + return { ok: false, status: 404 } as unknown as Response; + }); + + const resolved = await resolveMaintainerFundingAddress({ + name: "soroban-sdk", + ecosystem: "cargo", + }); + + expect(resolved).toBe(mockStellar); + }); + + it("resolves address for npm dependency via registry and root FUNDING.json", async () => { + const mockStellar = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + + globalThis.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = url.toString(); + if (u.includes("registry.npmjs.org/stellar-sdk")) { + return { + ok: true, + json: async () => ({ + repository: { url: "git+https://github.com/stellar/js-stellar-sdk.git" }, + }), + } as unknown as Response; + } + if (u.includes(".github/FUNDING.json")) { + return { ok: false, status: 404 } as unknown as Response; + } + if (u.includes("FUNDING.json")) { + return { + ok: true, + text: async () => + JSON.stringify({ + accounts: [{ network: "stellar", address: mockStellar }], + }), + } as unknown as Response; + } + return { ok: false, status: 404 } as unknown as Response; + }); + + const resolved = await resolveMaintainerFundingAddress({ + name: "stellar-sdk", + ecosystem: "npm", + }); + + expect(resolved).toBe(mockStellar); + }); + + it("fails silently and returns null on network errors", async () => { + globalThis.fetch = vi.fn(async () => { + throw new Error("Network offline"); + }); + + const resolved = await resolveMaintainerFundingAddress({ + name: "unknown-pkg", + ecosystem: "cargo", + }); + + expect(resolved).toBeNull(); + }); +}); diff --git a/frontend/src/lib/manifest.ts b/frontend/src/lib/manifest.ts index b4e4d68..d761914 100644 --- a/frontend/src/lib/manifest.ts +++ b/frontend/src/lib/manifest.ts @@ -423,3 +423,137 @@ export async function fetchRepoManifest(repo: string): Promise<{ text: string; f } throw new Error(`No Cargo.toml or package.json at the root of ${slug}`); } + + +// ---------- FUNDING.json Resolution ---------- + +export const STELLAR_ADDR_REGEX = /^G[A-Z2-7]{55}$/; + +/** + * Extracts a normalized "owner/repo" slug from various GitHub repository URL formats. + */ +export function extractGitHubRepoSlug(url: string): string | null { + if (!url || typeof url !== "string") return null; + const clean = url + .replace(/^git\+/, "") + .replace(/^ssh:\/\/git@github\.com\/?/, "https://github.com/") + .replace(/^git@github\.com:/, "https://github.com/") + .replace(/\.git$/, "") + .trim(); + + const m = clean.match(/^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/?$/); + if (m) { + return `${m[1]}/${m[2]}`; + } + return null; +} + +/** + * Safely parses a FUNDING.json file string and extracts an Ed25519 Stellar public key address (G...). + */ +export function extractStellarAddressFromFundingJson(text: string): string | null { + try { + const data = JSON.parse(text); + if (!data || typeof data !== "object") return null; + + if (typeof data.stellar === "string" && STELLAR_ADDR_REGEX.test(data.stellar.trim())) { + return data.stellar.trim(); + } + if (typeof data.address === "string" && STELLAR_ADDR_REGEX.test(data.address.trim())) { + return data.address.trim(); + } + if (data.drips && typeof data.drips === "object") { + if (typeof data.drips.stellar === "string" && STELLAR_ADDR_REGEX.test(data.drips.stellar.trim())) { + return data.drips.stellar.trim(); + } + } + if (Array.isArray(data.accounts)) { + for (const item of data.accounts) { + if ( + item && + typeof item === "object" && + (item.network === "stellar" || item.chain === "stellar" || item.platform === "stellar") && + typeof item.address === "string" && + STELLAR_ADDR_REGEX.test(item.address.trim()) + ) { + return item.address.trim(); + } + } + } + if (data.accounts && typeof data.accounts === "object" && !Array.isArray(data.accounts)) { + if (typeof data.accounts.stellar === "string" && STELLAR_ADDR_REGEX.test(data.accounts.stellar.trim())) { + return data.accounts.stellar.trim(); + } + } + return null; + } catch { + return null; + } +} + +/** + * Queries registry metadata (crates.io / npm) to find the GitHub repository URL for a package. + */ +export async function fetchDependencyRepoSlug( + dep: Pick +): Promise { + try { + if (dep.ecosystem === "cargo") { + const res = await fetch(`https://crates.io/api/v1/crates/${encodeURIComponent(dep.name)}`); + if (!res.ok) return null; + const json = await res.json(); + const repoUrl = json?.crate?.repository; + return repoUrl ? extractGitHubRepoSlug(repoUrl) : null; + } else { + const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(dep.name)}`); + if (!res.ok) return null; + const json = await res.json(); + const repo = json?.repository; + const repoUrl = typeof repo === "string" ? repo : repo?.url; + return repoUrl ? extractGitHubRepoSlug(repoUrl) : null; + } + } catch { + return null; + } +} + +/** + * Attempts to fetch and extract a Stellar address from FUNDING.json in a GitHub repository. + */ +export async function fetchFundingJsonAddress(repoSlug: string): Promise { + const candidatePaths = [ + `https://raw.githubusercontent.com/${repoSlug}/HEAD/.github/FUNDING.json`, + `https://raw.githubusercontent.com/${repoSlug}/HEAD/FUNDING.json`, + ]; + + for (const url of candidatePaths) { + try { + const res = await fetch(url); + if (res.ok) { + const text = await res.text(); + const addr = extractStellarAddressFromFundingJson(text); + if (addr) return addr; + } + } catch { + // Continue to next path + } + } + return null; +} + +/** + * Non-blocking maintainer address resolver: checks crates.io / npm for repository, + * then checks GitHub repository for FUNDING.json. Silently returns null on failure. + */ +export async function resolveMaintainerFundingAddress( + dep: Pick, + knownRepoSlug?: string +): Promise { + try { + const slug = knownRepoSlug || (await fetchDependencyRepoSlug(dep)); + if (!slug) return null; + return await fetchFundingJsonAddress(slug); + } catch { + return null; + } +} diff --git a/frontend/src/pages/Cascades.tsx b/frontend/src/pages/Cascades.tsx index f7ac376..6575fdd 100644 --- a/frontend/src/pages/Cascades.tsx +++ b/frontend/src/pages/Cascades.tsx @@ -4,7 +4,7 @@ import { useApp } from "../state"; import { splitter, type Payout } from "../lib/contracts"; import { config, XLM_SAC } from "../lib/config"; import { cleanError, sv } from "../lib/soroban"; -import { equalWeights, fetchRepoManifest, parseManifest, registryUrl, type Dependency, type Manifest } from "../lib/manifest"; +import { equalWeights, fetchRepoManifest, parseManifest, registryUrl, resolveMaintainerFundingAddress, type Dependency, type Manifest } from "../lib/manifest"; import { fromUnits, shortAddr, toUnits } from "../lib/format"; import { Banner } from "../components/Shell"; import { CopyButton, Empty, Field, Icon, SectionTitle, Spinner } from "../components/ui"; @@ -15,6 +15,7 @@ interface Row { recipient: string; bps: number; ecosystem?: Dependency["ecosystem"]; + resolvedFromFunding?: boolean; } const MAX_SPLITS = 32; @@ -78,12 +79,37 @@ export function CascadesPage() { const deps = m.deps.filter((d) => includeDev || d.kind === "normal" || d.kind === "peer").slice(0, MAX_SPLITS); // Default: 90% across deps, 10% reserve for the project itself. const w = equalWeights(deps.length, 9000); - setRows( - deps.map((d, i) => { - const existing = rows.find((r) => r.label === d.name); - return { key: `${d.name}-${i}`, label: d.name, recipient: existing?.recipient ?? "", bps: w[i], ecosystem: d.ecosystem }; - }), - ); + const initialRows: Row[] = deps.map((d, i) => { + const existing = rows.find((r) => r.label === d.name); + return { + key: `${d.name}-${i}`, + label: d.name, + recipient: existing?.recipient ?? "", + bps: w[i], + ecosystem: d.ecosystem, + resolvedFromFunding: existing?.resolvedFromFunding, + }; + }); + setRows(initialRows); + + // Non-blocking auto-resolution of maintainer Stellar addresses from FUNDING.json + deps.forEach(async (d, i) => { + if (initialRows[i].recipient) return; + try { + const addr = await resolveMaintainerFundingAddress(d); + if (addr) { + setRows((current) => + current.map((r, idx) => + idx === i && !r.recipient + ? { ...r, recipient: addr, resolvedFromFunding: true } + : r + ) + ); + } + } catch { + // Silent and non-blocking + } + }); } function parse() { @@ -334,6 +360,23 @@ export function CascadesPage() { {r.ecosystem === "cargo" ? "crate" : "npm"} ↗ )} + {r.resolvedFromFunding && ( + + resolved from FUNDING.json + + )} + {!r.recipient && ( + + )}
Date: Sat, 7 Nov 2026 00:04:04 +0700 Subject: [PATCH 08/10] feat(cascades): suggest split weights from download counts (fixes #5) --- frontend/src/lib/__tests__/weights.test.ts | 181 +++++++++++++++++++++ frontend/src/lib/manifest.ts | 134 +++++++++++++++ frontend/src/pages/Cascades.tsx | 28 +++- 3 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/__tests__/weights.test.ts diff --git a/frontend/src/lib/__tests__/weights.test.ts b/frontend/src/lib/__tests__/weights.test.ts new file mode 100644 index 0000000..c6e790a --- /dev/null +++ b/frontend/src/lib/__tests__/weights.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + allocateWeightsByDownloads, + fetchCrateDownloads, + fetchNpmDownloads, + fetchPackageDownloads, + suggestDependencyWeights, + type Dependency, +} from "../manifest"; + +describe("allocateWeightsByDownloads", () => { + it("returns empty array for empty inputs", () => { + expect(allocateWeightsByDownloads([])).toEqual([]); + }); + + it("allocates entire targetBps to a single package", () => { + expect(allocateWeightsByDownloads([12345], 9000)).toEqual([9000]); + expect(allocateWeightsByDownloads([0], 9000)).toEqual([9000]); + }); + + it("strictly sums to targetBps across varied download counts with no drift", () => { + const downloads = [1000, 2500, 50, 8000, 12000, 300]; + const targetBps = 9000; + const minFloorBps = 50; + const weights = allocateWeightsByDownloads(downloads, targetBps, minFloorBps); + + expect(weights.length).toBe(downloads.length); + const sum = weights.reduce((acc, w) => acc + w, 0); + expect(sum).toBe(targetBps); + + // Each element respects the minFloorBps + weights.forEach((w) => { + expect(w).toBeGreaterThanOrEqual(minFloorBps); + }); + + // Higher downloads get higher or equal weights + expect(weights[4]).toBeGreaterThan(weights[3]); + expect(weights[3]).toBeGreaterThan(weights[1]); + expect(weights[1]).toBeGreaterThan(weights[0]); + }); + + it("falls back to equal distribution when all downloads are 0", () => { + const downloads = [0, 0, 0, 0]; + const targetBps = 9000; + const weights = allocateWeightsByDownloads(downloads, targetBps); + expect(weights).toEqual([2250, 2250, 2250, 2250]); + expect(weights.reduce((a, b) => a + b, 0)).toBe(targetBps); + }); + + it("handles negative or NaN downloads gracefully", () => { + const downloads = [-100, NaN, 1000, 2000]; + const targetBps = 9000; + const minFloorBps = 50; + const weights = allocateWeightsByDownloads(downloads, targetBps, minFloorBps); + expect(weights.reduce((a, b) => a + b, 0)).toBe(targetBps); + expect(weights[0]).toBe(minFloorBps); + expect(weights[1]).toBe(minFloorBps); + expect(weights[2]).toBeGreaterThan(minFloorBps); + expect(weights[3]).toBeGreaterThan(weights[2]); + }); + + it("scales down floor safely when n * minFloorBps exceeds targetBps", () => { + // 100 items with floor 100 = 10000 > 9000 + const downloads = new Array(100).fill(10); + const weights = allocateWeightsByDownloads(downloads, 9000, 100); + expect(weights.length).toBe(100); + expect(weights.reduce((a, b) => a + b, 0)).toBe(9000); + weights.forEach((w) => { + expect(w).toBe(90); + }); + }); + + it("distributes fractional remainders using Hamilton-Hare Largest Remainder Method", () => { + // Let n = 3, targetBps = 1000, minFloorBps = 100 + // unreserved = 700 + // downloads = [100, 200, 400], sum = 700 + // quotas: + // dep 0: (100 / 700) * 700 = 100.0 (remainder 0) + // dep 1: (200 / 700) * 700 = 200.0 (remainder 0) + // dep 2: (400 / 700) * 700 = 400.0 (remainder 0) + // alloc = [100 + 100, 100 + 200, 100 + 400] = [200, 300, 500] + const weights = allocateWeightsByDownloads([100, 200, 400], 1000, 100); + expect(weights).toEqual([200, 300, 500]); + expect(weights.reduce((a, b) => a + b, 0)).toBe(1000); + }); +}); + +describe("download count fetchers", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("fetchCrateDownloads parses crates.io JSON response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + crate: { + downloads: 482910, + }, + }), + } as unknown as Response); + + const downloads = await fetchCrateDownloads("soroban-sdk"); + expect(downloads).toBe(482910); + expect(globalThis.fetch).toHaveBeenCalledWith("https://crates.io/api/v1/crates/soroban-sdk"); + globalThis.fetch = originalFetch; + }); + + it("fetchCrateDownloads returns 0 on HTTP failure", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + } as unknown as Response); + + const downloads = await fetchCrateDownloads("unknown-crate"); + expect(downloads).toBe(0); + globalThis.fetch = originalFetch; + }); + + it("fetchNpmDownloads parses api.npmjs.org JSON response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + downloads: 125099, + package: "express", + }), + } as unknown as Response); + + const downloads = await fetchNpmDownloads("express"); + expect(downloads).toBe(125099); + expect(globalThis.fetch).toHaveBeenCalledWith("https://api.npmjs.org/downloads/point/last-month/express"); + globalThis.fetch = originalFetch; + }); + + it("fetchPackageDownloads dispatches correctly according to ecosystem", async () => { + globalThis.fetch = vi.fn().mockImplementation(async (url: string) => { + if (url.includes("crates.io")) { + return { + ok: true, + json: async () => ({ crate: { downloads: 500 } }), + }; + } + return { + ok: true, + json: async () => ({ downloads: 1500 }), + }; + }); + + const crateDl = await fetchPackageDownloads({ name: "serde", ecosystem: "cargo" }); + const npmDl = await fetchPackageDownloads({ name: "lodash", ecosystem: "npm" }); + + expect(crateDl).toBe(500); + expect(npmDl).toBe(1500); + globalThis.fetch = originalFetch; + }); + + it("suggestDependencyWeights aggregates downloads in parallel and calculates weights", async () => { + globalThis.fetch = vi.fn().mockImplementation(async (url: string) => { + if (url.includes("soroban-sdk")) { + return { ok: true, json: async () => ({ crate: { downloads: 3000 } }) }; + } + if (url.includes("ed25519-dalek")) { + return { ok: true, json: async () => ({ crate: { downloads: 7000 } }) }; + } + return { ok: false }; + }); + + const deps: Pick[] = [ + { name: "soroban-sdk", ecosystem: "cargo" }, + { name: "ed25519-dalek", ecosystem: "cargo" }, + ]; + + const weights = await suggestDependencyWeights(deps, 9000, 50); + expect(weights.length).toBe(2); + expect(weights.reduce((a, b) => a + b, 0)).toBe(9000); + expect(weights[1]).toBeGreaterThan(weights[0]); + globalThis.fetch = originalFetch; + }); +}); diff --git a/frontend/src/lib/manifest.ts b/frontend/src/lib/manifest.ts index d761914..570c720 100644 --- a/frontend/src/lib/manifest.ts +++ b/frontend/src/lib/manifest.ts @@ -557,3 +557,137 @@ export async function resolveMaintainerFundingAddress( return null; } } + + +// ---------- Download Count Weight Suggestion ---------- + +/** + * Pure weight allocator based on package downloads using the Hamilton-Hare Largest Remainder Method. + * Guarantees that the sum of allocated basis points strictly equals `targetBps` with no drift. + * Ensures each package receives at least `minFloorBps` when feasible. + * Falls back gracefully to `equalWeights` when all downloads are 0 or empty. + */ +export function allocateWeightsByDownloads( + downloads: number[], + targetBps = 9000, + minFloorBps = 50 +): number[] { + const n = downloads.length; + if (n === 0) return []; + if (n === 1) return [targetBps]; + + // If even allocating the minimum floor exceeds targetBps, scale down floor + const effectiveFloor = n * minFloorBps <= targetBps ? minFloorBps : Math.floor(targetBps / n); + + // Clean downloads array (replace negatives or NaNs with 0) + const cleanDownloads = downloads.map((d) => (typeof d === "number" && !isNaN(d) && d > 0 ? d : 0)); + const totalDownloads = cleanDownloads.reduce((acc, d) => acc + d, 0); + + // If no package has any downloads, distribute evenly + if (totalDownloads <= 0) { + return equalWeights(n, targetBps); + } + + const unreservedBps = targetBps - n * effectiveFloor; + if (unreservedBps <= 0) { + return equalWeights(n, targetBps); + } + + // Initial floor allocation + const alloc = new Array(n).fill(effectiveFloor); + + // Calculate proportional quotas and split into integer and fractional parts + const fractions: { index: number; remainder: number; downloads: number }[] = []; + let allocatedAboveFloor = 0; + + for (let i = 0; i < n; i++) { + const rawQuota = (cleanDownloads[i] / totalDownloads) * unreservedBps; + const intQuota = Math.floor(rawQuota); + const remainder = rawQuota - intQuota; + + alloc[i] += intQuota; + allocatedAboveFloor += intQuota; + fractions.push({ index: i, remainder, downloads: cleanDownloads[i] }); + } + + let remainingBps = unreservedBps - allocatedAboveFloor; + + // Hamilton-Hare largest remainder distribution: + // Sort descending by remainder fraction. Tie-break by highest download count, then original index. + fractions.sort((a, b) => { + if (Math.abs(b.remainder - a.remainder) > 1e-9) { + return b.remainder - a.remainder; + } + if (b.downloads !== a.downloads) { + return b.downloads - a.downloads; + } + return a.index - b.index; + }); + + for (let i = 0; i < remainingBps && i < fractions.length; i++) { + alloc[fractions[i].index] += 1; + } + + return alloc; +} + +/** + * Fetches all-time total downloads for a Rust crate from crates.io. + */ +export async function fetchCrateDownloads(name: string): Promise { + try { + const res = await fetch(`https://crates.io/api/v1/crates/${encodeURIComponent(name)}`); + if (!res.ok) return 0; + const json = await res.json(); + return Number(json?.crate?.downloads) || 0; + } catch { + return 0; + } +} + +/** + * Fetches last-month download counts for an npm package from api.npmjs.org. + */ +export async function fetchNpmDownloads(name: string): Promise { + try { + const res = await fetch(`https://api.npmjs.org/downloads/point/last-month/${encodeURIComponent(name)}`); + if (!res.ok) return 0; + const json = await res.json(); + return Number(json?.downloads) || 0; + } catch { + return 0; + } +} + +/** + * Fetches download counts for a dependency across crates.io or npm. + */ +export async function fetchPackageDownloads(dep: Pick): Promise { + if (dep.ecosystem === "cargo") { + return fetchCrateDownloads(dep.name); + } else { + return fetchNpmDownloads(dep.name); + } +} + +/** + * Queries download metrics for a list of dependencies in parallel and calculates + * proportional split weights in basis points (strictly summing to `targetBps`). + */ +export async function suggestDependencyWeights( + deps: Pick[], + targetBps = 9000, + minFloorBps = 50 +): Promise { + if (deps.length === 0) return []; + if (deps.length === 1) return [targetBps]; + + try { + const downloadCounts = await Promise.all( + deps.map((dep) => fetchPackageDownloads(dep).catch(() => 0)) + ); + return allocateWeightsByDownloads(downloadCounts, targetBps, minFloorBps); + } catch { + return equalWeights(deps.length, targetBps); + } +} diff --git a/frontend/src/pages/Cascades.tsx b/frontend/src/pages/Cascades.tsx index 6575fdd..dda9376 100644 --- a/frontend/src/pages/Cascades.tsx +++ b/frontend/src/pages/Cascades.tsx @@ -4,7 +4,7 @@ import { useApp } from "../state"; import { splitter, type Payout } from "../lib/contracts"; import { config, XLM_SAC } from "../lib/config"; import { cleanError, sv } from "../lib/soroban"; -import { equalWeights, fetchRepoManifest, parseManifest, registryUrl, resolveMaintainerFundingAddress, type Dependency, type Manifest } from "../lib/manifest"; +import { equalWeights, fetchRepoManifest, parseManifest, registryUrl, resolveMaintainerFundingAddress, suggestDependencyWeights, type Dependency, type Manifest } from "../lib/manifest"; import { fromUnits, shortAddr, toUnits } from "../lib/format"; import { Banner } from "../components/Shell"; import { CopyButton, Empty, Field, Icon, SectionTitle, Spinner } from "../components/ui"; @@ -44,6 +44,7 @@ export function CascadesPage() { const [onChain, setOnChain] = useState(null); const [loadingChain, setLoadingChain] = useState(false); const [includeDev, setIncludeDev] = useState(false); + const [suggestingWeights, setSuggestingWeights] = useState(false); // Preview / distribute const [owner, setOwner] = useState(config.readSource); // demo table until a wallet connects @@ -315,9 +316,28 @@ export function CascadesPage() { icon="lan" aside={ rows.length > 0 && ( - +
+ + +
) } > From fc00264867b6166a9e508e650e4ad370bdc71b51 Mon Sep 17 00:00:00 2001 From: NePreby Date: Sat, 7 Nov 2026 00:09:07 +0700 Subject: [PATCH 09/10] feat(theme): dark mode for drafting design system (fixes #10) --- frontend/src/components/Shell.tsx | 29 ++++++- frontend/src/index.css | 80 ++++++++++++++++-- frontend/src/lib/__tests__/theme.test.ts | 101 +++++++++++++++++++++++ frontend/src/lib/theme.ts | 83 +++++++++++++++++++ frontend/src/main.tsx | 4 + 5 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/__tests__/theme.test.ts create mode 100644 frontend/src/lib/theme.ts diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx index f5bb6de..f2af69e 100644 --- a/frontend/src/components/Shell.tsx +++ b/frontend/src/components/Shell.tsx @@ -5,6 +5,7 @@ import { fundWithFriendbot, xlmBalance } from "../lib/wallet"; import { fromUnits, shortAddr } from "../lib/format"; import { config } from "../lib/config"; import { CopyButton, Icon, Pip, Spinner } from "./ui"; +import { applyTheme, getStoredTheme, type Theme } from "../lib/theme"; export type Route = "streams" | "cascades" | "ci" | "earnings"; @@ -45,6 +46,32 @@ function useNetwork() { return { ledger, fee, online }; } + +function ThemeToggle() { + const [theme, setThemeState] = useState(() => getStoredTheme()); + + const cycle = () => { + const next: Theme = theme === "system" ? "dark" : theme === "dark" ? "light" : "system"; + setThemeState(next); + applyTheme(next); + }; + + const icon = theme === "dark" ? "dark_mode" : theme === "light" ? "light_mode" : "settings_brightness"; + const title = `Theme: ${theme} (click to cycle system/dark/light)`; + + return ( + + ); +} + function WalletButton() { const { address, connect, connecting, disconnect, toast, version } = useApp(); const [open, setOpen] = useState(false); @@ -151,7 +178,7 @@ export function Shell({ route, onRoute, children }: { route: Route; onRoute: (r: ))} - +
diff --git a/frontend/src/index.css b/frontend/src/index.css index 710b1f5..1842f13 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,6 +2,35 @@ /* Architectural Drafting Standard — see design/systems/architectural_drafting_standard/DESIGN.md */ @theme { + --color-paper: var(--color-paper); + --color-vellum: var(--color-vellum); + --color-canvas: var(--color-canvas); + --color-line: var(--color-line); + --color-line-faint: var(--color-line-faint); + --color-ink: var(--color-ink); + --color-ink-2: var(--color-ink-2); + --color-lead: var(--color-lead); + --color-cobalt: var(--color-cobalt); + --color-cobalt-hover: var(--color-cobalt-hover); + --color-cobalt-active: var(--color-cobalt-active); + --color-ok: var(--color-ok); + --color-ok-bg: var(--color-ok-bg); + --color-err: var(--color-err); + --color-err-bg: var(--color-err-bg); + --color-warn: var(--color-warn); + --color-warn-bg: var(--color-warn-bg); + + --font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif; + --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; + + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; +} + +:root { --color-paper: #ffffff; --color-vellum: #f6f8fa; --color-canvas: #f7f9ff; @@ -19,15 +48,48 @@ --color-err-bg: #ffebe9; --color-warn: #9a6700; --color-warn-bg: #fff8c5; +} - --font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif; - --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif; - --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --color-paper: #161b22; + --color-vellum: #0d1117; + --color-canvas: #090d13; + --color-line: #30363d; + --color-line-faint: #21262d; + --color-ink: #f0f6fc; + --color-ink-2: #c9d1d9; + --color-lead: #8b949e; + --color-cobalt: #58a6ff; + --color-cobalt-hover: #79c0ff; + --color-cobalt-active: #388bfd; + --color-ok: #3fb950; + --color-ok-bg: #032b13; + --color-err: #f85149; + --color-err-bg: #3d1214; + --color-warn: #d29922; + --color-warn-bg: #342403; + } +} - --radius-xs: 0.125rem; - --radius-sm: 0.25rem; - --radius-md: 0.375rem; - --radius-lg: 0.5rem; +:root.dark { + --color-paper: #161b22; + --color-vellum: #0d1117; + --color-canvas: #090d13; + --color-line: #30363d; + --color-line-faint: #21262d; + --color-ink: #f0f6fc; + --color-ink-2: #c9d1d9; + --color-lead: #8b949e; + --color-cobalt: #58a6ff; + --color-cobalt-hover: #79c0ff; + --color-cobalt-active: #388bfd; + --color-ok: #3fb950; + --color-ok-bg: #032b13; + --color-err: #f85149; + --color-err-bg: #3d1214; + --color-warn: #d29922; + --color-warn-bg: #342403; } @layer base { @@ -96,8 +158,8 @@ @apply border-b border-line-faint last:border-b-0 hover:bg-vellum; } .floating { - border: 1px solid var(--color-ink); - box-shadow: 2px 2px 0 0 rgb(15 23 42 / 0.08); + border: 1px solid var(--color-line); + box-shadow: 2px 2px 0 0 rgb(15 23 42 / 0.15); } } diff --git a/frontend/src/lib/__tests__/theme.test.ts b/frontend/src/lib/__tests__/theme.test.ts new file mode 100644 index 0000000..b4d32c7 --- /dev/null +++ b/frontend/src/lib/__tests__/theme.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { applyTheme, getResolvedTheme, getStoredTheme } from "../theme"; + +describe("Theme Management", () => { + let store: Record = {}; + + beforeEach(() => { + store = {}; + const mockStorage = { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + removeItem: (k: string) => { + delete store[k]; + }, + clear: () => { + store = {}; + }, + }; + + vi.stubGlobal("localStorage", mockStorage); + vi.stubGlobal("window", { + localStorage: mockStorage, + matchMedia: (query: string) => ({ + matches: query.includes("dark"), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }); + + const classSet = new Set(); + vi.stubGlobal("document", { + documentElement: { + classList: { + add: (...cls: string[]) => cls.forEach((c) => classSet.add(c)), + remove: (...cls: string[]) => cls.forEach((c) => classSet.delete(c)), + contains: (c: string) => classSet.has(c), + }, + }, + }); + }); + + it("defaults to system theme when nothing is stored", () => { + expect(getStoredTheme()).toBe("system"); + }); + + it("persists theme to localStorage and sets root class", () => { + applyTheme("dark"); + expect(getStoredTheme()).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect(document.documentElement.classList.contains("light")).toBe(false); + + applyTheme("light"); + expect(getStoredTheme()).toBe("light"); + expect(document.documentElement.classList.contains("light")).toBe(true); + expect(document.documentElement.classList.contains("dark")).toBe(false); + + applyTheme("system"); + expect(getStoredTheme()).toBe("system"); + expect(document.documentElement.classList.contains("light")).toBe(false); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }); + + it("resolves dark/light when system preference is queried", () => { + expect(getResolvedTheme("system")).toBe("dark"); + expect(getResolvedTheme("light")).toBe("light"); + expect(getResolvedTheme("dark")).toBe("dark"); + }); + + it("calculates contrast ratio conforming to WCAG AA (>= 4.5:1)", () => { + function luminance(r: number, g: number, b: number): number { + const a = [r, g, b].map((v) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }); + return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]; + } + + function contrastRatio(hex1: string, hex2: string): number { + const rgb1 = [parseInt(hex1.slice(1, 3), 16), parseInt(hex1.slice(3, 5), 16), parseInt(hex1.slice(5, 7), 16)]; + const rgb2 = [parseInt(hex2.slice(1, 3), 16), parseInt(hex2.slice(3, 5), 16), parseInt(hex2.slice(5, 7), 16)]; + const l1 = luminance(rgb1[0], rgb1[1], rgb1[2]); + const l2 = luminance(rgb2[0], rgb2[1], rgb2[2]); + const brighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (brighter + 0.05) / (darker + 0.05); + } + + // Dark mode tokens: + // paper background: #161b22, canvas background: #090d13 + // ink text: #f0f6fc, ink-2 text: #c9d1d9, lead text: #8b949e + const contrastInkOnCanvas = contrastRatio("#f0f6fc", "#090d13"); + const contrastInk2OnPaper = contrastRatio("#c9d1d9", "#161b22"); + const contrastLeadOnPaper = contrastRatio("#8b949e", "#161b22"); + + expect(contrastInkOnCanvas).toBeGreaterThanOrEqual(12.0); + expect(contrastInk2OnPaper).toBeGreaterThanOrEqual(8.0); + expect(contrastLeadOnPaper).toBeGreaterThanOrEqual(4.5); + }); +}); diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts new file mode 100644 index 0000000..e4599b2 --- /dev/null +++ b/frontend/src/lib/theme.ts @@ -0,0 +1,83 @@ +export type Theme = "light" | "dark" | "system"; + +const STORAGE_KEY = "flowpulse_theme"; + +function getStorage(): Storage | null { + try { + if (typeof localStorage !== "undefined") return localStorage; + if (typeof window !== "undefined" && window.localStorage) return window.localStorage; + } catch { + // Storage access may throw in restricted iframes + } + return null; +} + +/** + * Returns current stored theme preference ("light" | "dark" | "system"). + */ +export function getStoredTheme(): Theme { + const s = getStorage(); + if (!s) return "system"; + const val = s.getItem(STORAGE_KEY); + if (val === "light" || val === "dark") return val; + return "system"; +} + +/** + * Resolves effective theme ("light" | "dark") based on current preference and system media query. + */ +export function getResolvedTheme(pref: Theme = getStoredTheme()): "light" | "dark" { + if (pref === "dark") return "dark"; + if (pref === "light") return "light"; + if (typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + return "light"; +} + +/** + * Applies the given theme preference to the document element (html) and persists in storage. + */ +export function applyTheme(theme: Theme): void { + const root = typeof document !== "undefined" ? document.documentElement : null; + const s = getStorage(); + + if (theme === "system") { + if (s) s.removeItem(STORAGE_KEY); + if (root) root.classList.remove("dark", "light"); + } else { + if (s) s.setItem(STORAGE_KEY, theme); + if (root) { + if (theme === "dark") { + root.classList.add("dark"); + root.classList.remove("light"); + } else { + root.classList.add("light"); + root.classList.remove("dark"); + } + } + } +} + +/** + * Initializes theme listener on app startup. + */ +export function initTheme(): () => void { + const current = getStoredTheme(); + applyTheme(current); + + if (typeof window === "undefined" || !window.matchMedia) return () => {}; + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const listener = () => { + if (getStoredTheme() === "system") { + applyTheme("system"); + } + }; + + if (media.addEventListener) { + media.addEventListener("change", listener); + return () => media.removeEventListener("change", listener); + } + return () => {}; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9438d6d..e9ea9fb 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { AppProvider } from "./state"; +import { initTheme } from "./lib/theme"; import App from "./App"; import "./index.css"; +// Initialize system/stored dark mode preference +initTheme(); + // Reveal icon glyphs only once the symbol font is ready (avoids raw ligature text). document.fonts ?.load('20px "Material Symbols Outlined"') From 1c4e6bbd5aba75c5da771ba76060a4de5e0383aa Mon Sep 17 00:00:00 2001 From: NePreby Date: Sat, 7 Nov 2026 00:15:12 +0700 Subject: [PATCH 10/10] feat(earnings): event-sourced payout history via rpc.getEvents (fixes #9) --- frontend/src/lib/__tests__/events.test.ts | 172 ++++++++++++++ frontend/src/lib/events.ts | 265 ++++++++++++++++++++++ frontend/src/pages/Earnings.tsx | 116 ++++++++-- 3 files changed, 534 insertions(+), 19 deletions(-) create mode 100644 frontend/src/lib/__tests__/events.test.ts create mode 100644 frontend/src/lib/events.ts diff --git a/frontend/src/lib/__tests__/events.test.ts b/frontend/src/lib/__tests__/events.test.ts new file mode 100644 index 0000000..5ed4d70 --- /dev/null +++ b/frontend/src/lib/__tests__/events.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { decodeContractEvent, mergeEventsWithReceipts, type DecodedPayoutEvent } from "../events"; +import { Address, xdr } from "@stellar/stellar-sdk"; +import type { Receipt } from "../receipts"; + +const TEST_ADDR_1 = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; +const TEST_ADDR_2 = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +describe("Event-Sourced Payout History Helper", () => { + it("decodes a Withdrawn event correctly", () => { + const raw = { + id: "0000000001", + contractId: "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7ZQVNCZOQ", + txHash: "abcd1234ef567890", + ledger: 105230, + ledgerClosedAt: "2026-11-06T12:00:00Z", + topic: [ + xdr.ScVal.scvSymbol("withdrawn"), + xdr.ScVal.scvU64(xdr.Uint64(42n)), + new Address(TEST_ADDR_1).toScVal(), + ], + value: xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("amount"), + val: xdr.ScVal.scvI128(new xdr.Int128Parts({ lo: xdr.Uint64(5000000n), hi: xdr.Int64(0n) })), + }), + ]), + }; + + const decoded = decodeContractEvent(raw); + expect(decoded).not.toBeNull(); + expect(decoded?.topicName).toBe("withdrawn"); + expect(decoded?.functionLabel).toBe("Stream Withdrawal"); + expect(decoded?.streamId).toBe(42); + expect(decoded?.amount).toBe("5000000"); + expect(decoded?.account).toBe(TEST_ADDR_1); + expect(decoded?.source).toBe("rpc"); + }); + + it("decodes a Created event correctly", () => { + const raw = { + id: "0000000002", + contractId: "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7ZQVNCZOQ", + txHash: "tx_created_999", + ledger: 105240, + ledgerClosedAt: "2026-11-06T12:05:00Z", + topic: [ + xdr.ScVal.scvSymbol("created"), + xdr.ScVal.scvU64(xdr.Uint64(10n)), + new Address(TEST_ADDR_1).toScVal(), + new Address(TEST_ADDR_2).toScVal(), + ], + value: xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("deposit"), + val: xdr.ScVal.scvI128(new xdr.Int128Parts({ lo: xdr.Uint64(10000000n), hi: xdr.Int64(0n) })), + }), + ]), + }; + + const decoded = decodeContractEvent(raw); + expect(decoded).not.toBeNull(); + expect(decoded?.topicName).toBe("created"); + expect(decoded?.functionLabel).toBe("Opened micro-stream"); + expect(decoded?.streamId).toBe(10); + expect(decoded?.account).toBe(TEST_ADDR_1); + expect(decoded?.counterparty).toBe(TEST_ADDR_2); + expect(decoded?.amount).toBe("10000000"); + }); + + it("decodes a Distributed event from CascadeSplitter", () => { + const raw = { + id: "0000000003", + contractId: "CB4D5KRYM6CB7OWQ6TWYRR3Z4T7ZQVNCZOQ", + txHash: "tx_distributed_777", + ledger: 105250, + ledgerClosedAt: "2026-11-06T12:10:00Z", + topic: [ + xdr.ScVal.scvSymbol("distributed"), + new Address(TEST_ADDR_1).toScVal(), + new Address(TEST_ADDR_2).toScVal(), + ], + value: xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("amount"), + val: xdr.ScVal.scvI128(new xdr.Int128Parts({ lo: xdr.Uint64(25000000n), hi: xdr.Int64(0n) })), + }), + ]), + }; + + const decoded = decodeContractEvent(raw); + expect(decoded).not.toBeNull(); + expect(decoded?.topicName).toBe("distributed"); + expect(decoded?.functionLabel).toBe("Cascade distribution"); + expect(decoded?.account).toBe(TEST_ADDR_1); + expect(decoded?.counterparty).toBe(TEST_ADDR_2); + expect(decoded?.amount).toBe("25000000"); + }); + + it("gracefully returns null for unknown or invalid topics", () => { + const invalid = { + id: "000", + contractId: "C1", + topic: [], + value: null, + }; + expect(decodeContractEvent(invalid)).toBeNull(); + }); + + it("merges RPC events with local receipts and de-duplicates by txHash", () => { + const rpcEvent: DecodedPayoutEvent = { + id: "ev1", + txHash: "hash_shared", + contractId: "C1", + ledger: 100, + ledgerClosedAt: "2026-11-06T10:00:00Z", + topicName: "withdrawn", + functionLabel: "Stream Withdrawal", + account: "GADDR1", + source: "rpc", + }; + + const receipt1: Receipt = { + digest: { alg: "SHA-256", canonicalization: "RFC8785", value: "sha256-1" }, + receipt: { + schema: "flowpulse.receipt/v1", + network: "testnet", + network_passphrase: "Test SDF Network ; September 2015", + contract_id: "C1", + function: "withdraw_all", + args: {}, + tx_hash: "hash_shared", + ledger: 100, + ledger_close_time: "2026-11-06T10:00:00Z", + source: "GADDR1", + envelope_xdr: "AAAA...", + }, + }; + + const receipt2: Receipt = { + digest: { alg: "SHA-256", canonicalization: "RFC8785", value: "sha256-2" }, + receipt: { + schema: "flowpulse.receipt/v1", + network: "testnet", + network_passphrase: "Test SDF Network ; September 2015", + contract_id: "C1", + function: "create_stream", + args: {}, + tx_hash: "hash_local_only", + ledger: 90, + ledger_close_time: "2026-11-06T09:00:00Z", + source: "GADDR1", + envelope_xdr: "BBBB...", + }, + }; + + const items = mergeEventsWithReceipts([rpcEvent], [receipt1, receipt2]); + + expect(items.length).toBe(2); + const sharedItem = items.find((i) => i.txHash === "hash_shared"); + expect(sharedItem).toBeDefined(); + expect(sharedItem?.receipt).toBe(receipt1); + expect(sharedItem?.source).toBe("rpc"); + + const localItem = items.find((i) => i.txHash === "hash_local_only"); + expect(localItem).toBeDefined(); + expect(localItem?.source).toBe("receipt"); + + expect(items[0].txHash).toBe("hash_shared"); + expect(items[1].txHash).toBe("hash_local_only"); + }); +}); diff --git a/frontend/src/lib/events.ts b/frontend/src/lib/events.ts new file mode 100644 index 0000000..6e5e5cb --- /dev/null +++ b/frontend/src/lib/events.ts @@ -0,0 +1,265 @@ +import { scValToNative, xdr } from "@stellar/stellar-sdk"; +import { config } from "./config"; +import { server, withRetry } from "./soroban"; +import type { Receipt } from "./receipts"; + +export interface DecodedPayoutEvent { + id: string; + txHash: string; + contractId: string; + ledger: number; + ledgerClosedAt: string; + topicName: "created" | "withdrawn" | "topped_up" | "cancelled" | "splits_set" | "distributed" | "unknown"; + functionLabel: string; + account: string; + counterparty?: string; + amount?: string; + source: "rpc" | "receipt"; + streamId?: number; +} + +export interface LedgerItem { + key: string; + txHash: string; + title: string; + functionName: string; + timestamp: number; + formattedTime: string; + amount?: string; + account: string; + counterparty?: string; + source: "rpc" | "receipt"; + receipt?: Receipt; +} + +/** + * Safely decodes an individual Soroban event topic from ScVal or base64 XDR string. + */ +export function decodeTopicVal(raw: unknown): unknown { + if (!raw) return null; + try { + if (typeof raw === "string") { + const val = xdr.ScVal.fromXDR(raw, "base64"); + return scValToNative(val); + } + return scValToNative(raw as xdr.ScVal); + } catch { + return raw; + } +} + +/** + * Safely decodes an individual Soroban event value from ScVal or base64 XDR string. + */ +export function decodeValueVal(raw: unknown): unknown { + if (!raw) return null; + try { + if (typeof raw === "string") { + const val = xdr.ScVal.fromXDR(raw, "base64"); + return scValToNative(val); + } + return scValToNative(raw as xdr.ScVal); + } catch { + return raw; + } +} + +/** + * Pure decoder: translates raw RPC contract event response into a typed DecodedPayoutEvent. + */ +export function decodeContractEvent(ev: { + id: string; + contractId: string; + txHash?: string; + ledger?: number; + ledgerClosedAt?: string; + topic: unknown[]; + value: unknown; +}): DecodedPayoutEvent | null { + try { + if (!ev || !Array.isArray(ev.topic) || ev.topic.length === 0) return null; + + const topicZero = String(decodeTopicVal(ev.topic[0]) ?? "").toLowerCase(); + const dataVal = decodeValueVal(ev.value) as Record | null; + + let topicName: DecodedPayoutEvent["topicName"] = "unknown"; + let functionLabel = "Contract Event"; + let account = ""; + let counterparty: string | undefined; + let amount: string | undefined; + let streamId: number | undefined; + + if (topicZero === "withdrawn" || topicZero === "withdraw") { + topicName = "withdrawn"; + functionLabel = "Stream Withdrawal"; + if (ev.topic.length > 1) streamId = Number(decodeTopicVal(ev.topic[1])); + if (ev.topic.length > 2) account = String(decodeTopicVal(ev.topic[2]) ?? ""); + if (dataVal && "amount" in dataVal) { + amount = String(dataVal.amount); + } else if (typeof dataVal === "bigint" || typeof dataVal === "number") { + amount = String(dataVal); + } + } else if (topicZero === "created" || topicZero === "create_stream") { + topicName = "created"; + functionLabel = "Opened micro-stream"; + if (ev.topic.length > 1) streamId = Number(decodeTopicVal(ev.topic[1])); + if (ev.topic.length > 2) account = String(decodeTopicVal(ev.topic[2]) ?? ""); // sender + if (ev.topic.length > 3) counterparty = String(decodeTopicVal(ev.topic[3]) ?? ""); // recipient + if (dataVal && "deposit" in dataVal) amount = String(dataVal.deposit); + } else if (topicZero === "topped_up" || topicZero === "top_up") { + topicName = "topped_up"; + functionLabel = "Stream top-up"; + if (ev.topic.length > 1) streamId = Number(decodeTopicVal(ev.topic[1])); + if (dataVal && "amount" in dataVal) amount = String(dataVal.amount); + } else if (topicZero === "cancelled" || topicZero === "cancel") { + topicName = "cancelled"; + functionLabel = "Stream cancelled"; + if (ev.topic.length > 1) streamId = Number(decodeTopicVal(ev.topic[1])); + if (dataVal && "paid_to_recipient" in dataVal) amount = String(dataVal.paid_to_recipient); + } else if (topicZero === "distributed" || topicZero === "distribute") { + topicName = "distributed"; + functionLabel = "Cascade distribution"; + if (ev.topic.length > 1) account = String(decodeTopicVal(ev.topic[1]) ?? ""); // owner + if (ev.topic.length > 2) counterparty = String(decodeTopicVal(ev.topic[2]) ?? ""); // from + if (dataVal && "amount" in dataVal) amount = String(dataVal.amount); + } else if (topicZero === "splits_set" || topicZero === "set_splits") { + topicName = "splits_set"; + functionLabel = "Published split table"; + if (ev.topic.length > 1) account = String(decodeTopicVal(ev.topic[1]) ?? ""); + } else { + return null; + } + + return { + id: ev.id, + txHash: ev.txHash ?? "", + contractId: String(ev.contractId), + ledger: ev.ledger ?? 0, + ledgerClosedAt: ev.ledgerClosedAt ?? new Date().toISOString(), + topicName, + functionLabel, + account, + counterparty, + amount, + source: "rpc", + streamId, + }; + } catch { + return null; + } +} + +/** + * Queries getEvents from Soroban RPC for the StreamRouter and CascadeSplitter contracts. + * Falls back gracefully to empty array on network errors or RPC retention pruning. + */ +export async function fetchContractEvents( + targetAddress: string, + lookbackLedgers = 10000 +): Promise { + try { + const latest = await withRetry(() => server.getLatestLedger()); + const startLedger = Math.max(1, latest.sequence - lookbackLedgers); + + const contractIds = [config.streamRouterId, config.cascadeSplitterId].filter(Boolean); + if (contractIds.length === 0) return []; + + const response = await withRetry(() => + server.getEvents({ + startLedger, + filters: [ + { + type: "contract", + contractIds, + }, + ], + }) + ); + + const events: DecodedPayoutEvent[] = []; + + for (const raw of response.events) { + const decoded = decodeContractEvent({ + id: raw.id, + contractId: raw.contractId ? String(raw.contractId) : "", + txHash: raw.txHash, + ledger: raw.ledger, + ledgerClosedAt: raw.ledgerClosedAt, + topic: raw.topic as unknown[], + value: raw.value as unknown, + }); + + if (decoded) { + // Filter by connected address (as sender, recipient, owner, or counterparty) + const matchesAddress = + !targetAddress || + decoded.account === targetAddress || + decoded.counterparty === targetAddress; + + if (matchesAddress) { + events.push(decoded); + } + } + } + + return events; + } catch { + // Graceful fallback when RPC is unreachable or queries beyond retention window + return []; + } +} + +/** + * Merges RPC-discovered events with locally cached receipts. + * De-duplicates by transaction hash, prioritizing RPC verified items. + */ +export function mergeEventsWithReceipts( + rpcEvents: DecodedPayoutEvent[], + localReceipts: Receipt[] +): LedgerItem[] { + const map = new Map(); + + // Add RPC events first + for (const ev of rpcEvents) { + const timeMs = Date.parse(ev.ledgerClosedAt) || Date.now(); + const key = ev.txHash || ev.id; + map.set(key, { + key, + txHash: ev.txHash, + title: ev.functionLabel, + functionName: ev.topicName, + timestamp: timeMs, + formattedTime: ev.ledgerClosedAt, + amount: ev.amount, + account: ev.account, + counterparty: ev.counterparty, + source: "rpc", + }); + } + + // Merge local receipts (filling gaps or attaching receipt objects for audit inspection) + for (const r of localReceipts) { + const txHash = r.receipt.tx_hash; + const timeMs = Date.parse(r.receipt.ledger_close_time) || Date.now(); + const existing = map.get(txHash); + + if (existing) { + existing.receipt = r; + } else { + map.set(txHash, { + key: txHash, + txHash, + title: r.receipt.function, + functionName: r.receipt.function, + timestamp: timeMs, + formattedTime: r.receipt.ledger_close_time, + account: r.receipt.source ?? "", + source: "receipt", + receipt: r, + }); + } + } + + // Sort descending by timestamp + return Array.from(map.values()).sort((a, b) => b.timestamp - a.timestamp); +} diff --git a/frontend/src/pages/Earnings.tsx b/frontend/src/pages/Earnings.tsx index 2cb6343..93a7dd9 100644 --- a/frontend/src/pages/Earnings.tsx +++ b/frontend/src/pages/Earnings.tsx @@ -6,26 +6,44 @@ import { config, XLM_SAC } from "../lib/config"; import { sv } from "../lib/soroban"; import { fromUnits, shortAddr, timeAgo } from "../lib/format"; import { loadReceipts, parseReceipt, type Receipt } from "../lib/receipts"; +import { fetchContractEvents, mergeEventsWithReceipts, type DecodedPayoutEvent } from "../lib/events"; import { xlmBalance } from "../lib/wallet"; import { Banner } from "../components/Shell"; import { CopyButton, Empty, Icon, Pip, SectionTitle, useNow } from "../components/ui"; const FWD_KEY = "flowpulse.autoforward.v1"; -function useReceipts() { - const [list, setList] = useState(loadReceipts); +function usePayoutLedger(address?: string) { + const [receipts, setReceipts] = useState(loadReceipts); + const [rpcEvents, setRpcEvents] = useState([]); + const [loadingRpc, setLoadingRpc] = useState(false); + useEffect(() => { - const on = () => setList(loadReceipts()); + const on = () => setReceipts(loadReceipts()); window.addEventListener("flowpulse:receipts", on); return () => window.removeEventListener("flowpulse:receipts", on); }, []); - return list; + + useEffect(() => { + if (!address) { + setRpcEvents([]); + return; + } + setLoadingRpc(true); + fetchContractEvents(address) + .then((evs) => setRpcEvents(evs)) + .catch(() => {}) + .finally(() => setLoadingRpc(false)); + }, [address]); + + const items = useMemo(() => mergeEventsWithReceipts(rpcEvents, receipts), [rpcEvents, receipts]); + return { items, loadingRpc, receiptCount: receipts.length }; } export function EarningsPage() { const { address, connect, runTx, openReceipt, toast, version } = useApp(); const { streams, scope } = useStreams(); - const receipts = useReceipts(); + const { items: ledgerItems, loadingRpc, receiptCount } = usePayoutLedger(address ?? undefined); const now = useNow(100); const nowSec = now / 1000; const [balance, setBalance] = useState(null); @@ -211,26 +229,86 @@ export function EarningsPage() {
- {receipts.length} receipts}> + + {loadingRpc && } + {ledgerItems.length} transactions + · + {receiptCount} local receipts + + } + > Payout ledger - {receipts.length === 0 ? ( - Every transaction you sign here produces an RFC-8785 audit receipt, stored in this browser. + {ledgerItems.length === 0 ? ( + + {loadingRpc + ? "Querying Soroban contract events from RPC..." + : "No on-chain events or local receipts found for this address."} + ) : (
- {receipts.slice(0, 20).map((r) => ( - + +
+ {item.receipt ? ( + + ) : ( + + RPC Event ↗ + + )}
- Settled - +
))}
)}