-
+
+ );
+}
+
+/** Architectural drafting blueprint illustration for recursive dependency split cascades */
+function CascadesDraftingIllustration() {
+ return (
+
+ );
+}
+
+/** Architectural drafting blueprint illustration for RFC-8785 cryptographic audit receipts */
+function ReceiptsDraftingIllustration() {
+ return (
+
+ );
+}
+
+/** General blueprint drafting drawing for other states */
+function DefaultDraftingIllustration() {
+ return (
+
+ );
+}
+
+export type EmptyIllustrationVariant = "streams" | "cascades" | "receipts" | "default";
+
+export function Empty({
+ icon,
+ title,
+ variant,
+ children,
+}: {
+ icon?: string;
+ title: string;
+ variant?: EmptyIllustrationVariant;
+ children?: ReactNode;
+}) {
+ const resolvedVariant: EmptyIllustrationVariant =
+ variant ??
+ (icon === "water_drop"
+ ? "streams"
+ : icon === "account_tree"
+ ? "cascades"
+ : icon === "receipt_long" || icon === "receipt" || icon === "account_balance_wallet"
+ ? "receipts"
+ : "default");
+
+ return (
+
+
+ {resolvedVariant === "streams" && }
+ {resolvedVariant === "cascades" && }
+ {resolvedVariant === "receipts" && }
+ {resolvedVariant === "default" && }
+
{title}
{children &&
{children}
}
diff --git a/frontend/src/lib/__tests__/cli.test.ts b/frontend/src/lib/__tests__/cli.test.ts
new file mode 100644
index 0000000..6ffa856
--- /dev/null
+++ b/frontend/src/lib/__tests__/cli.test.ts
@@ -0,0 +1,214 @@
+import { describe, it, expect } from "vitest";
+import { toStellarCliCommand, formatCliValue } from "../cli";
+import { sv } from "../soroban";
+
+const ROUTER_ID = "CA45PGMYO5CNRPCMW3THPKI26PK2CU3JU4XTJZJ3HZ4KLEFRKUQXUTWK";
+const SPLITTER_ID = "CA4U6VUIFA33SQA5QCQYMFNEQTNN6XV43EAZ67IKXOB434V5GI5KB2SY";
+const ADDR_A = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";
+const ADDR_B = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
+const TOKEN_ADDR = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
+
+describe("toStellarCliCommand", () => {
+ describe("StreamRouter methods", () => {
+ it("generates CLI command for create_stream", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "create_stream",
+ args: [
+ sv.addr(ADDR_A),
+ sv.addr(ADDR_B),
+ sv.addr(TOKEN_ADDR),
+ sv.i128(100000n),
+ sv.i128(5000000n),
+ ],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- create_stream --sender ${ADDR_A} --recipient ${ADDR_B} --token ${TOKEN_ADDR} --rate_per_sec 100000 --deposit 5000000`
+ );
+ });
+
+ it("generates CLI command for withdraw", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "withdraw",
+ args: [sv.u64(12)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- withdraw --id 12`
+ );
+ });
+
+ it("generates CLI command for withdraw_all", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "withdraw_all",
+ args: [sv.addr(ADDR_B)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- withdraw_all --recipient ${ADDR_B}`
+ );
+ });
+
+ it("generates CLI command for top_up", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "top_up",
+ args: [sv.u64(12), sv.i128(2000000n)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- top_up --id 12 --amount 2000000`
+ );
+ });
+
+ it("generates CLI command for cancel", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "cancel",
+ args: [sv.u64(12)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- cancel --id 12`
+ );
+ });
+
+ it("generates CLI command for withdrawable", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "withdrawable",
+ args: [sv.u64(12)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- withdrawable --id 12`
+ );
+ });
+
+ it("generates CLI command for get_stream", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "get_stream",
+ args: [sv.u64(12)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- get_stream --id 12`
+ );
+ });
+
+ it("generates CLI command for streams_by_sender", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "streams_by_sender",
+ args: [sv.addr(ADDR_A)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- streams_by_sender --sender ${ADDR_A}`
+ );
+ });
+
+ it("generates CLI command for streams_by_recipient", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "streams_by_recipient",
+ args: [sv.addr(ADDR_B)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- streams_by_recipient --recipient ${ADDR_B}`
+ );
+ });
+
+ it("generates CLI command for stream_count", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "stream_count",
+ args: [],
+ });
+ expect(cmd).toBe(`stellar contract invoke --id ${ROUTER_ID} -- stream_count`);
+ });
+ });
+
+ describe("CascadeSplitter methods", () => {
+ it("generates CLI command for set_splits", () => {
+ const splits = [
+ { recipient: ADDR_B, bps: 5000, label: "serde" },
+ ];
+ const cmd = toStellarCliCommand({
+ contractId: SPLITTER_ID,
+ method: "set_splits",
+ args: [sv.addr(ADDR_A), sv.splits(splits)],
+ });
+ expect(cmd).toContain(`stellar contract invoke --id ${SPLITTER_ID} -- set_splits --owner ${ADDR_A} --splits `);
+ expect(cmd).toContain('"bps":5000');
+ expect(cmd).toContain('"label":"serde"');
+ expect(cmd).toContain(`"recipient":"${ADDR_B}"`);
+ });
+
+ it("generates CLI command for get_splits", () => {
+ const cmd = toStellarCliCommand({
+ contractId: SPLITTER_ID,
+ method: "get_splits",
+ args: [sv.addr(ADDR_A)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${SPLITTER_ID} -- get_splits --owner ${ADDR_A}`
+ );
+ });
+
+ it("generates CLI command for preview", () => {
+ const cmd = toStellarCliCommand({
+ contractId: SPLITTER_ID,
+ method: "preview",
+ args: [sv.addr(ADDR_A), sv.i128(10000000n)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${SPLITTER_ID} -- preview --owner ${ADDR_A} --amount 10000000`
+ );
+ });
+
+ it("generates CLI command for distribute", () => {
+ const cmd = toStellarCliCommand({
+ contractId: SPLITTER_ID,
+ method: "distribute",
+ args: [
+ sv.addr(ADDR_A),
+ sv.addr(ADDR_B),
+ sv.addr(TOKEN_ADDR),
+ sv.i128(10000000n),
+ ],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${SPLITTER_ID} -- distribute --from ${ADDR_A} --owner ${ADDR_B} --token ${TOKEN_ADDR} --amount 10000000`
+ );
+ });
+ });
+
+ describe("Flags and formatting options", () => {
+ it("includes --network and --source when supplied", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "withdraw",
+ args: [sv.u64(99)],
+ source: ADDR_A,
+ network: "testnet",
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} --source ${ADDR_A} --network testnet -- withdraw --id 99`
+ );
+ });
+
+ it("accepts plain record objects as arguments", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "top_up",
+ args: { id: 10, amount: "5000" },
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- top_up --id 10 --amount 5000`
+ );
+ });
+
+ it("safely escapes shell values with spaces", () => {
+ expect(formatCliValue("hello world")).toBe("'hello world'");
+ expect(formatCliValue(123)).toBe("123");
+ expect(formatCliValue(456n)).toBe("456");
+ });
+ });
+});
diff --git a/frontend/src/lib/__tests__/focus.test.ts b/frontend/src/lib/__tests__/focus.test.ts
new file mode 100644
index 0000000..becb129
--- /dev/null
+++ b/frontend/src/lib/__tests__/focus.test.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi } from "vitest";
+import { handleFocusTrap } from "../focus";
+
+describe("handleFocusTrap", () => {
+ function createMockContainer(elements: { tag: string; id: string; disabled?: boolean }[]) {
+ const container = {
+ contains: vi.fn((el) => elements.some((e) => e.id === el?.id)),
+ querySelectorAll: vi.fn((_selector: string) => {
+ return elements.map((item) => ({
+ id: item.id,
+ tagName: item.tag.toUpperCase(),
+ focus: vi.fn(),
+ getAttribute: vi.fn((attr: string) => (attr === "disabled" && item.disabled ? "" : null)),
+ style: {},
+ }));
+ }),
+ } as unknown as HTMLElement;
+ return container;
+ }
+
+ it("ignores non-Tab key events", () => {
+ const container = createMockContainer([{ tag: "button", id: "btn1" }]);
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Enter", shiftKey: false, preventDefault },
+ activeElement: null,
+ });
+
+ expect(handled).toBe(false);
+ expect(preventDefault).not.toHaveBeenCalled();
+ });
+
+ it("wraps from last to first on Tab", () => {
+ const elements = [
+ { id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ { id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ ];
+ const container = {
+ contains: vi.fn(() => true),
+ querySelectorAll: vi.fn(() => elements),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: false, preventDefault },
+ activeElement: elements[1] as any, // Last element
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(elements[0].focus).toHaveBeenCalled();
+ });
+
+ it("wraps from first to last on Shift+Tab", () => {
+ const elements = [
+ { id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ { id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ ];
+ const container = {
+ contains: vi.fn(() => true),
+ querySelectorAll: vi.fn(() => elements),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: true, preventDefault },
+ activeElement: elements[0] as any, // First element
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(elements[1].focus).toHaveBeenCalled();
+ });
+
+ it("focuses first element if focus is outside container on Tab", () => {
+ const elements = [
+ { id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ { id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ ];
+ const container = {
+ contains: vi.fn(() => false),
+ querySelectorAll: vi.fn(() => elements),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: false, preventDefault },
+ activeElement: { id: "outside" } as any,
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(elements[0].focus).toHaveBeenCalled();
+ });
+
+ it("prevents default if no focusable elements are present", () => {
+ const container = {
+ contains: vi.fn(() => true),
+ querySelectorAll: vi.fn(() => []),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: false, preventDefault },
+ activeElement: null,
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ });
+});
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/__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/__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/__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/cli.ts b/frontend/src/lib/cli.ts
new file mode 100644
index 0000000..1b6d432
--- /dev/null
+++ b/frontend/src/lib/cli.ts
@@ -0,0 +1,94 @@
+import { scValToNative, xdr } from "@stellar/stellar-sdk";
+
+export const METHOD_SIGNATURES: Record = {
+ // StreamRouter
+ create_stream: ["sender", "recipient", "token", "rate_per_sec", "deposit"],
+ withdraw: ["id"],
+ withdraw_all: ["recipient"],
+ top_up: ["id", "amount"],
+ cancel: ["id"],
+ withdrawable: ["id"],
+ get_stream: ["id"],
+ streams_by_sender: ["sender"],
+ streams_by_recipient: ["recipient"],
+ stream_count: [],
+
+ // CascadeSplitter
+ set_splits: ["owner", "splits"],
+ get_splits: ["owner"],
+ preview: ["owner", "amount"],
+ distribute: ["from", "owner", "token", "amount"],
+};
+
+export interface FormatCliOptions {
+ contractId: string;
+ method: string;
+ args?: xdr.ScVal[] | Record | Array;
+ network?: string;
+ source?: string;
+}
+
+/**
+ * Format a value into a safe shell argument representation for Stellar CLI.
+ */
+export function formatCliValue(val: unknown): string {
+ if (typeof val === "bigint" || typeof val === "number") {
+ return val.toString();
+ }
+ if (typeof val === "string") {
+ // If it contains spaces or quotes, wrap in quotes
+ if (/[\s"']/.test(val)) {
+ return `'${val.replace(/'/g, "'\\''")}'`;
+ }
+ return val;
+ }
+ if (typeof val === "boolean") {
+ return val ? "true" : "false";
+ }
+ // Array or object (e.g. splits struct or JSON payload)
+ const json = JSON.stringify(val);
+ return `'${json.replace(/'/g, "'\\''")}'`;
+}
+
+/**
+ * Generate the equivalent `stellar contract invoke` CLI command for a Soroban call.
+ */
+export function toStellarCliCommand({
+ contractId,
+ method,
+ args = [],
+ network,
+ source,
+}: FormatCliOptions): string {
+ const parts: string[] = ["stellar", "contract", "invoke", "--id", contractId];
+
+ if (source) {
+ parts.push("--source", source);
+ }
+ if (network) {
+ parts.push("--network", network);
+ }
+
+ parts.push("--", method);
+
+ let namedArgs: Record = {};
+
+ if (Array.isArray(args)) {
+ const signature = METHOD_SIGNATURES[method];
+ args.forEach((arg, index) => {
+ // Decode ScVal if applicable
+ const nativeVal =
+ arg instanceof xdr.ScVal ? scValToNative(arg as xdr.ScVal) : arg;
+ const paramName = signature && signature[index] ? signature[index] : `arg${index + 1}`;
+ namedArgs[paramName] = nativeVal;
+ });
+ } else if (args && typeof args === "object") {
+ namedArgs = args as Record;
+ }
+
+ for (const [key, val] of Object.entries(namedArgs)) {
+ parts.push(`--${key}`, formatCliValue(val));
+ }
+
+ return parts.join(" ");
+}
diff --git a/frontend/src/lib/focus.ts b/frontend/src/lib/focus.ts
new file mode 100644
index 0000000..2d6c0e7
--- /dev/null
+++ b/frontend/src/lib/focus.ts
@@ -0,0 +1,53 @@
+export const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+export interface FocusTrapOptions {
+ container: HTMLElement;
+ event: { key: string; shiftKey: boolean; preventDefault: () => void };
+ activeElement: Element | null;
+}
+
+/**
+ * Handles Tab and Shift+Tab key navigation to keep focus trapped within the container.
+ * Returns true if the event was handled (trapped) and default prevented.
+ */
+export function handleFocusTrap({
+ container,
+ event,
+ activeElement,
+}: FocusTrapOptions): boolean {
+ if (event.key !== "Tab") return false;
+
+ const focusables = Array.from(
+ container.querySelectorAll(FOCUSABLE_SELECTOR)
+ ).filter((el) => {
+ // In JSDOM / headless, offsetParent might be null, so check display/visibility if needed
+ const style = (el as any).style;
+ if (style && (style.display === "none" || style.visibility === "hidden")) return false;
+ return true;
+ });
+
+ if (focusables.length === 0) {
+ event.preventDefault();
+ return true;
+ }
+
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+
+ if (event.shiftKey) {
+ if (activeElement === first || !container.contains(activeElement)) {
+ event.preventDefault();
+ last.focus();
+ return true;
+ }
+ } else {
+ if (activeElement === last || !container.contains(activeElement)) {
+ event.preventDefault();
+ first.focus();
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/frontend/src/lib/manifest.ts b/frontend/src/lib/manifest.ts
index 306d01f..570c720 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
@@ -157,3 +423,271 @@ 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;
+ }
+}
+
+
+// ---------- 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/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("");
+}
diff --git a/frontend/src/pages/Cascades.tsx b/frontend/src/pages/Cascades.tsx
index f7ac376..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, 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";
@@ -15,6 +15,7 @@ interface Row {
recipient: string;
bps: number;
ecosystem?: Dependency["ecosystem"];
+ resolvedFromFunding?: boolean;
}
const MAX_SPLITS = 32;
@@ -43,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
@@ -78,12 +80,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() {
@@ -289,9 +316,28 @@ export function CascadesPage() {
icon="lan"
aside={
rows.length > 0 && (
-
+
+
+
+
)
}
>
@@ -334,6 +380,23 @@ export function CascadesPage() {
{r.ecosystem === "cargo" ? "crate" : "npm"} ↗
)}
+ {r.resolvedFromFunding && (
+
+ resolved from FUNDING.json
+
+ )}
+ {!r.recipient && (
+
+ )}