-
+
+ {/* Corner Crosshairs */}
+
+
+
+
+
+ {/* Blueprint Outer Frame with Hairline Grid */}
+
+
+ {/* Axis Guide Lines */}
+
+
+
+ {/* Linear Accrual Stream Pipeline Waveform */}
+
+
+ {/* Terminal Metering Nodes */}
+
+
+
+ {/* Dimensional Metric Tick Marks */}
+
+
+
+
+ );
+}
+
+/** Architectural drafting blueprint illustration for recursive dependency split cascades */
+function CascadesDraftingIllustration() {
+ return (
+
+ {/* Corner Crosshairs */}
+
+
+
+
+
+ {/* Blueprint Outer Frame */}
+
+
+ {/* Root Node Box */}
+
+
+
+ {/* Orthogonal Split Branching Vectors */}
+
+
+
+
+
+ {/* Junction Dots */}
+
+
+ {/* Tier 1 Dependency Blocks */}
+
+
+
+ {/* Tier 2 Cascade Hop */}
+
+
+
+ {/* Dimension & Weight Ratio Ticks */}
+
+
+
+ );
+}
+
+/** Architectural drafting blueprint illustration for RFC-8785 cryptographic audit receipts */
+function ReceiptsDraftingIllustration() {
+ return (
+
+ {/* Corner Crosshairs */}
+
+
+
+
+
+ {/* Blueprint Outer Frame */}
+
+
+ {/* Cryptographic Ledger Ticket Outline */}
+
+
+ {/* Hairline Digest Barcode Vector */}
+
+
+ {/* Ledger Canonical Hash Rows */}
+
+
+
+ {/* Verification Seal Target (Concentric Hairline Rings with Crosshair Axis) */}
+
+
+
+
+
+ );
+}
+
+/** General blueprint drafting drawing for other states */
+function DefaultDraftingIllustration() {
+ return (
+
+ {/* Corner Crosshairs */}
+
+
+
+
+
+ {/* Blueprint Schematic Grid */}
+
+
+
+
+
+
+ );
+}
+
+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/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__/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__/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/__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__/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/__tests__/tokens.test.ts b/frontend/src/lib/__tests__/tokens.test.ts
new file mode 100644
index 0000000..b10a781
--- /dev/null
+++ b/frontend/src/lib/__tests__/tokens.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { SUPPORTED_TOKENS, tokenMeta, XLM_SAC, USDC_SAC, USDC_ISSUER } from "../config";
+import { hasTrustline, tokenBalance } from "../wallet";
+
+describe("Token Metadata and Trustline Support", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("returns verified metadata for native XLM SAC", () => {
+ const meta = tokenMeta(XLM_SAC);
+ expect(meta.code).toBe("XLM");
+ expect(meta.decimals).toBe(7);
+ expect(meta.name).toBe("Stellar Lumens");
+ expect(meta.contractId).toBe(XLM_SAC);
+ });
+
+ it("returns verified metadata for testnet USDC SAC", () => {
+ const meta = tokenMeta(USDC_SAC);
+ expect(meta.code).toBe("USDC");
+ expect(meta.decimals).toBe(7);
+ expect(meta.name).toBe("USD Coin (Testnet)");
+ expect(meta.issuer).toBe(USDC_ISSUER);
+ expect(meta.contractId).toBe(USDC_SAC);
+ });
+
+ it("registers both XLM and USDC in SUPPORTED_TOKENS", () => {
+ expect(SUPPORTED_TOKENS.length).toBe(2);
+ expect(SUPPORTED_TOKENS.some((t) => t.code === "XLM")).toBe(true);
+ expect(SUPPORTED_TOKENS.some((t) => t.code === "USDC")).toBe(true);
+ });
+
+ it("falls back to truncated contract address for unregistered tokens", () => {
+ const randomContract = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+ const meta = tokenMeta(randomContract);
+ expect(meta.code).toBe("CAAA…");
+ expect(meta.decimals).toBe(7);
+ });
+
+ it("hasTrustline identifies presence or absence of asset trustline", async () => {
+ const targetAddr = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";
+
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ balances: [
+ { asset_type: "native", balance: "100.0000000" },
+ {
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: USDC_ISSUER,
+ balance: "50.0000000",
+ },
+ ],
+ }),
+ } as unknown as Response);
+
+ const hasUsdc = await hasTrustline(targetAddr, "USDC", USDC_ISSUER);
+ expect(hasUsdc).toBe(true);
+
+ const hasOther = await hasTrustline(targetAddr, "EURC", USDC_ISSUER);
+ expect(hasOther).toBe(false);
+ });
+
+ it("tokenBalance extracts USDC balance from account record", async () => {
+ const targetAddr = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";
+
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ balances: [
+ { asset_type: "native", balance: "250.5000000" },
+ {
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: USDC_ISSUER,
+ balance: "1234.5600000",
+ },
+ ],
+ }),
+ } as unknown as Response);
+
+ const usdcBal = await tokenBalance(targetAddr, USDC_SAC);
+ expect(usdcBal).toBe("1234.5600000");
+
+ const xlmBal = await tokenBalance(targetAddr, XLM_SAC);
+ expect(xlmBal).toBe("250.5000000");
+ });
+});
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/config.ts b/frontend/src/lib/config.ts
index 3ed4b91..7fe06ac 100644
--- a/frontend/src/lib/config.ts
+++ b/frontend/src/lib/config.ts
@@ -22,10 +22,47 @@ export const config = {
/** Native XLM Stellar Asset Contract on testnet. */
export const XLM_SAC = Asset.native().contractId(Networks.TESTNET);
-export const TOKENS: Record = {
- [XLM_SAC]: { code: "XLM", decimals: 7 },
+/** Circle USDC testnet issuer & Stellar Asset Contract */
+export const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
+export const USDC_ASSET = new Asset("USDC", USDC_ISSUER);
+export const USDC_SAC = USDC_ASSET.contractId(Networks.TESTNET);
+
+export interface TokenInfo {
+ contractId: string;
+ code: string;
+ decimals: number;
+ name: string;
+ issuer?: string;
+}
+
+export const TOKENS: Record = {
+ [XLM_SAC]: {
+ contractId: XLM_SAC,
+ code: "XLM",
+ decimals: 7,
+ name: "Stellar Lumens",
+ },
+ [USDC_SAC]: {
+ contractId: USDC_SAC,
+ code: "USDC",
+ decimals: 7,
+ name: "USD Coin (Testnet)",
+ issuer: USDC_ISSUER,
+ },
};
-export function tokenMeta(address: string) {
- return TOKENS[address] ?? { code: address.slice(0, 4) + "…", decimals: 7 };
+export const SUPPORTED_TOKENS: TokenInfo[] = [
+ TOKENS[XLM_SAC],
+ TOKENS[USDC_SAC],
+];
+
+export function tokenMeta(address: string): TokenInfo {
+ return (
+ TOKENS[address] ?? {
+ contractId: address,
+ code: address.slice(0, 4) + "…",
+ decimals: 7,
+ name: "Unknown SAC Token",
+ }
+ );
}
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/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/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/lib/wallet.ts b/frontend/src/lib/wallet.ts
index f0735bb..2c910e5 100644
--- a/frontend/src/lib/wallet.ts
+++ b/frontend/src/lib/wallet.ts
@@ -5,7 +5,7 @@ import {
requestAccess,
signTransaction,
} from "@stellar/freighter-api";
-import { config } from "./config";
+import { config, XLM_SAC, USDC_SAC, USDC_ISSUER } from "./config";
export async function freighterInstalled(): Promise {
try {
@@ -75,3 +75,52 @@ export async function xlmBalance(address: string): Promise {
return null;
}
}
+
+/**
+ * Checks whether an account has an established trustline for a classic asset (e.g. USDC).
+ * Returns true if trustline exists, false if explicitly absent, and true if lookup fails (to not block offline/simulation).
+ */
+export async function hasTrustline(
+ address: string,
+ assetCode: string,
+ issuer: string
+): Promise {
+ try {
+ const res = await fetch(`${config.horizonUrl}/accounts/${address}`);
+ if (!res.ok) return true;
+ const j = await res.json();
+ if (!Array.isArray(j.balances)) return true;
+ return j.balances.some(
+ (b: { asset_code?: string; asset_issuer?: string }) =>
+ b.asset_code === assetCode && b.asset_issuer === issuer
+ );
+ } catch {
+ return true;
+ }
+}
+
+/**
+ * Fetches balance for any supported token (XLM, USDC) from Horizon.
+ */
+export async function tokenBalance(address: string, tokenAddress: string): Promise {
+ if (tokenAddress === XLM_SAC) {
+ return xlmBalance(address);
+ }
+ try {
+ const res = await fetch(`${config.horizonUrl}/accounts/${address}`);
+ if (!res.ok) return null;
+ const j = await res.json();
+ if (!Array.isArray(j.balances)) return null;
+
+ if (tokenAddress === USDC_SAC) {
+ const b = j.balances.find(
+ (x: { asset_code?: string; asset_issuer?: string }) =>
+ x.asset_code === "USDC" && x.asset_issuer === USDC_ISSUER
+ );
+ return b?.balance ?? "0";
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
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"')
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 && (
- setRows((rs) => { const w = equalWeights(rs.length, 9000); return rs.map((r, i) => ({ ...r, bps: w[i] })); })}>
- Equalize
-
+
+ {
+ setSuggestingWeights(true);
+ try {
+ const w = await suggestDependencyWeights(rows.map((r) => ({ name: r.label, ecosystem: r.ecosystem ?? "cargo" })));
+ setRows((rs) => rs.map((r, i) => ({ ...r, bps: w[i] ?? r.bps })));
+ } finally {
+ setSuggestingWeights(false);
+ }
+ }}
+ title="Suggest proportional weights based on download counts"
+ >
+ {suggestingWeights ? : } Suggest weights
+
+ setRows((rs) => { const w = equalWeights(rs.length, 9000); return rs.map((r, i) => ({ ...r, bps: w[i] })); })}>
+ Equalize
+
+
)
}
>
@@ -334,6 +380,23 @@ export function CascadesPage() {
{r.ecosystem === "cargo" ? "crate" : "npm"} ↗
)}
+ {r.resolvedFromFunding && (
+
+ resolved from FUNDING.json
+
+ )}
+ {!r.recipient && (
+ {
+ const addr = await resolveMaintainerFundingAddress({ name: r.label, ecosystem: r.ecosystem ?? "cargo" });
+ if (addr) update(i, { recipient: addr, resolvedFromFunding: true });
+ }}
+ >
+ Resolve FUNDING.json
+
+ )}
(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);
const [hasTable, setHasTable] = useState(false);
+ const [tokenFilter, setTokenFilter] = useState("all");
const [fwd, setFwd] = useState(() => {
try {
return Number(localStorage.getItem(FWD_KEY) ?? "0");
@@ -53,8 +72,12 @@ export function EarningsPage() {
}, [address, version]);
const mine = scope === "mine";
- const incoming = mine ? streams.filter((s) => s.recipient === address && s.token === XLM_SAC) : [];
- const outgoing = mine ? streams.filter((s) => s.sender === address && s.token === XLM_SAC) : [];
+ const incoming = mine
+ ? streams.filter((s) => s.recipient === address && (tokenFilter === "all" || s.token === tokenFilter))
+ : [];
+ const outgoing = mine
+ ? streams.filter((s) => s.sender === address && (tokenFilter === "all" || s.token === tokenFilter))
+ : [];
const unclaimedSmooth = incoming.reduce((a, s) => a + Math.max(0, streamedAtSmooth(s, now) - Number(s.withdrawn)), 0);
const unclaimed = incoming.reduce((a, s) => a + withdrawableAt(s, nowSec), 0n);
@@ -123,12 +146,33 @@ export function EarningsPage() {
0n} /> {rateIn > 0n ? "LIVE ACCRUAL" : "IDLE"}
Soroban p30
- Unclaimed streaming balance
+
+
Unclaimed streaming balance
+
+ setTokenFilter("all")}
+ className={`rounded-full border px-2 py-0.5 font-mono text-[10px] ${tokenFilter === "all" ? "border-cobalt bg-cobalt text-white" : "border-line bg-vellum text-ink-2"}`}
+ >
+ All
+
+ {SUPPORTED_TOKENS.map((t) => (
+ setTokenFilter(t.contractId)}
+ className={`rounded-full border px-2 py-0.5 font-mono text-[10px] ${tokenFilter === t.contractId ? "border-cobalt bg-cobalt text-white" : "border-line bg-vellum text-ink-2"}`}
+ >
+ {t.code}
+
+ ))}
+
+
{(unclaimedSmooth / 1e7).toLocaleString("en-US", { minimumFractionDigits: 4, maximumFractionDigits: 4 })}
- XLM
+ {tokenFilter === "all" ? "XLM/USDC" : tokenMeta(tokenFilter).code}
+{fromUnits(rateIn * 60n, 7, 4)} XLM/min
@@ -211,26 +255,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) => (
-
openReceipt(r)} className="flex w-full items-center gap-3 p-3 text-left hover:bg-vellum">
-
-
-
-
-
{LABEL[r.receipt.function] ?? r.receipt.function}
-
- {timeAgo(Date.parse(r.receipt.ledger_close_time))} · tx {r.receipt.tx_hash.slice(0, 8)}…
+ {ledgerItems.slice(0, 30).map((item) => (
+
+
{
+ if (item.receipt) {
+ openReceipt(item.receipt);
+ } else if (item.txHash) {
+ window.open(`${config.explorer}/tx/${item.txHash}`, "_blank");
+ }
+ }}
+ >
+
+
+
+
+
+
+ {LABEL[item.functionName] ?? item.title}
+
+ {item.amount && (
+
+ {fromUnits(BigInt(item.amount), 7, 2)} XLM
+
+ )}
+
+
+ {timeAgo(item.timestamp)} · tx {item.txHash ? `${item.txHash.slice(0, 8)}…` : "unrecorded"}
+
+
+
+
+ {item.receipt ? (
+
openReceipt(item.receipt!)}
+ className="chip-ok flex items-center gap-1 text-[10px]"
+ title="Click to view RFC-8785 signed receipt"
+ >
+ Receipt
+
+ ) : (
+
+ RPC Event ↗
+
+ )}
-
Settled
-
+
))}
)}
diff --git a/frontend/src/pages/Streams.tsx b/frontend/src/pages/Streams.tsx
index e4cc868..b26c9f6 100644
--- a/frontend/src/pages/Streams.tsx
+++ b/frontend/src/pages/Streams.tsx
@@ -250,11 +250,12 @@ function StreamCard({ s, me, now }: { s: Stream; me: string | null; now: number
{topUp && (
setTopUp(false)}
onSubmit={async (units) => {
- const r = await call("top_up", "Top up stream", [sv.u64(s.id), sv.i128(units)], {
+ const r = await call("top_up", `Top up ${t.code} stream`, [sv.u64(s.id), sv.i128(units)], {
id: s.id.toString(),
- amount: `${units} stroops (${fromUnits(units)} XLM)`,
+ amount: `${units} stroops (${fromUnits(units, t.decimals)} ${t.code})`,
});
if (r) setTopUp(false);
}}
@@ -264,17 +265,18 @@ function StreamCard({ s, me, now }: { s: Stream; me: string | null; now: number
);
}
-function TopUpSheet({ onClose, onSubmit }: { onClose: () => void; onSubmit: (units: bigint) => void }) {
+function TopUpSheet({ tokenAddress, onClose, onSubmit }: { tokenAddress: string; onClose: () => void; onSubmit: (units: bigint) => void }) {
+ const t = tokenMeta(tokenAddress);
const [v, setV] = useState("50");
let units: bigint | null = null;
try {
- units = toUnits(v);
+ units = toUnits(v, t.decimals);
} catch {
units = null;
}
return (
-
- Amount (XLM)
+
+ Amount ({t.code})
setV(e.target.value)} inputMode="decimal" />
units && onSubmit(units)}>
Review & sign