Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions core/registry/manifests.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { manifest as feeStats } from "@/features/fee-stats/manifest";
import { manifest as freighterConnect } from "@/features/freighter-connect/manifest";
import { manifest as hashCalculator } from "@/features/hash-calculator/manifest";
import { manifest as horizonHealth } from "@/features/horizon-health/manifest";
import { manifest as keypairGenerator } from "@/features/keypair-generator/manifest";
import { manifest as ledgerLookup } from "@/features/ledger-lookup/manifest";
import { manifest as ledgerRangePlanner } from "@/features/ledger-range-planner/manifest";
import { manifest as liquidityPoolCalculator } from "@/features/liquidity-pool-calculator/manifest";
Expand All @@ -50,7 +51,9 @@ import { manifest as preconditionsExplainer } from "@/features/preconditions-exp
import { manifest as priceFractionLab } from "@/features/price-fraction-lab/manifest";
import { manifest as reserveCalculator } from "@/features/reserve-calculator/manifest";
import { manifest as resultCodeExplainer } from "@/features/result-code-explainer/manifest";
import { manifest as scvalCodec } from "@/features/scval-codec/manifest";
import { manifest as sep10Inspector } from "@/features/sep10-inspector/manifest";
import { manifest as sep12Fields } from "@/features/sep12-fields/manifest";
import { manifest as sep7SignatureVerifier } from "@/features/sep7-signature-verifier/manifest";
import { manifest as sequenceInspector } from "@/features/sequence-inspector/manifest";
import { manifest as signatureVerifier } from "@/features/signature-verifier/manifest";
Expand Down Expand Up @@ -101,6 +104,7 @@ export const generatedManifests: FeatureManifest[] = [
freighterConnect,
hashCalculator,
horizonHealth,
keypairGenerator,
ledgerLookup,
ledgerRangePlanner,
liquidityPoolCalculator,
Expand All @@ -124,7 +128,9 @@ export const generatedManifests: FeatureManifest[] = [
priceFractionLab,
reserveCalculator,
resultCodeExplainer,
scvalCodec,
sep10Inspector,
sep12Fields,
sep7SignatureVerifier,
sequenceInspector,
signatureVerifier,
Expand Down
3 changes: 3 additions & 0 deletions core/registry/panels.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const generatedPanels: Record<string, ComponentType> = {
"freighter-connect": () => import("@/features/freighter-connect/panel").then((mod) => mod.default as ComponentType),
"hash-calculator": () => import("@/features/hash-calculator/panel").then((mod) => mod.default as ComponentType),
"horizon-health": () => import("@/features/horizon-health/panel").then((mod) => mod.default as ComponentType),
"keypair-generator": () => import("@/features/keypair-generator/panel").then((mod) => mod.default as ComponentType),
"ledger-lookup": () => import("@/features/ledger-lookup/panel").then((mod) => mod.default as ComponentType),
"ledger-range-planner": () => import("@/features/ledger-range-planner/panel").then((mod) => mod.default as ComponentType),
"liquidity-pool-calculator": () => import("@/features/liquidity-pool-calculator/panel").then((mod) => mod.default as ComponentType),
Expand All @@ -53,7 +54,9 @@ export const generatedPanels: Record<string, ComponentType> = {
"price-fraction-lab": () => import("@/features/price-fraction-lab/panel").then((mod) => mod.default as ComponentType),
"reserve-calculator": () => import("@/features/reserve-calculator/panel").then((mod) => mod.default as ComponentType),
"result-code-explainer": () => import("@/features/result-code-explainer/panel").then((mod) => mod.default as ComponentType),
"scval-codec": () => import("@/features/scval-codec/panel").then((mod) => mod.default as ComponentType),
"sep10-inspector": () => import("@/features/sep10-inspector/panel").then((mod) => mod.default as ComponentType),
"sep12-fields": () => import("@/features/sep12-fields/panel").then((mod) => mod.default as ComponentType),
"sep7-signature-verifier": () => import("@/features/sep7-signature-verifier/panel").then((mod) => mod.default as ComponentType),
"sequence-inspector": () => import("@/features/sequence-inspector/panel").then((mod) => mod.default as ComponentType),
"signature-verifier": () => import("@/features/signature-verifier/panel").then((mod) => mod.default as ComponentType),
Expand Down
6 changes: 6 additions & 0 deletions core/registry/registry.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { manifest as feeStats } from "@/features/fee-stats/manifest";
import { manifest as freighterConnect } from "@/features/freighter-connect/manifest";
import { manifest as hashCalculator } from "@/features/hash-calculator/manifest";
import { manifest as horizonHealth } from "@/features/horizon-health/manifest";
import { manifest as keypairGenerator } from "@/features/keypair-generator/manifest";
import { manifest as ledgerLookup } from "@/features/ledger-lookup/manifest";
import { manifest as ledgerRangePlanner } from "@/features/ledger-range-planner/manifest";
import { manifest as liquidityPoolCalculator } from "@/features/liquidity-pool-calculator/manifest";
Expand All @@ -51,7 +52,9 @@ import { manifest as preconditionsExplainer } from "@/features/preconditions-exp
import { manifest as priceFractionLab } from "@/features/price-fraction-lab/manifest";
import { manifest as reserveCalculator } from "@/features/reserve-calculator/manifest";
import { manifest as resultCodeExplainer } from "@/features/result-code-explainer/manifest";
import { manifest as scvalCodec } from "@/features/scval-codec/manifest";
import { manifest as sep10Inspector } from "@/features/sep10-inspector/manifest";
import { manifest as sep12Fields } from "@/features/sep12-fields/manifest";
import { manifest as sep7SignatureVerifier } from "@/features/sep7-signature-verifier/manifest";
import { manifest as sequenceInspector } from "@/features/sequence-inspector/manifest";
import { manifest as signatureVerifier } from "@/features/signature-verifier/manifest";
Expand Down Expand Up @@ -102,6 +105,7 @@ export const generatedFeatures: FeatureEntry[] = [
{ manifest: freighterConnect, load: async () => { const mod = await import("@/features/freighter-connect/panel"); return mod.default as ComponentType; } },
{ manifest: hashCalculator, load: async () => { const mod = await import("@/features/hash-calculator/panel"); return mod.default as ComponentType; } },
{ manifest: horizonHealth, load: async () => { const mod = await import("@/features/horizon-health/panel"); return mod.default as ComponentType; } },
{ manifest: keypairGenerator, load: async () => { const mod = await import("@/features/keypair-generator/panel"); return mod.default as ComponentType; } },
{ manifest: ledgerLookup, load: async () => { const mod = await import("@/features/ledger-lookup/panel"); return mod.default as ComponentType; } },
{ manifest: ledgerRangePlanner, load: async () => { const mod = await import("@/features/ledger-range-planner/panel"); return mod.default as ComponentType; } },
{ manifest: liquidityPoolCalculator, load: async () => { const mod = await import("@/features/liquidity-pool-calculator/panel"); return mod.default as ComponentType; } },
Expand All @@ -125,7 +129,9 @@ export const generatedFeatures: FeatureEntry[] = [
{ manifest: priceFractionLab, load: async () => { const mod = await import("@/features/price-fraction-lab/panel"); return mod.default as ComponentType; } },
{ manifest: reserveCalculator, load: async () => { const mod = await import("@/features/reserve-calculator/panel"); return mod.default as ComponentType; } },
{ manifest: resultCodeExplainer, load: async () => { const mod = await import("@/features/result-code-explainer/panel"); return mod.default as ComponentType; } },
{ manifest: scvalCodec, load: async () => { const mod = await import("@/features/scval-codec/panel"); return mod.default as ComponentType; } },
{ manifest: sep10Inspector, load: async () => { const mod = await import("@/features/sep10-inspector/panel"); return mod.default as ComponentType; } },
{ manifest: sep12Fields, load: async () => { const mod = await import("@/features/sep12-fields/panel"); return mod.default as ComponentType; } },
{ manifest: sep7SignatureVerifier, load: async () => { const mod = await import("@/features/sep7-signature-verifier/panel"); return mod.default as ComponentType; } },
{ manifest: sequenceInspector, load: async () => { const mod = await import("@/features/sequence-inspector/panel"); return mod.default as ComponentType; } },
{ manifest: signatureVerifier, load: async () => { const mod = await import("@/features/signature-verifier/panel"); return mod.default as ComponentType; } },
Expand Down
35 changes: 35 additions & 0 deletions features/keypair-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Testnet Keypair Generator

Generates a disposable Stellar testnet keypair locally in the browser. The
SDK's CSPRNG is used and no network request is made.

## How it works

The result contains a public key and a testnet secret for the current UI
session. Nothing is written to storage or sent to an API.

## Files

| Path | Responsibility |
| --- | --- |
| `manifest.ts` | Registry metadata |
| `schema.ts` | Input parsing and validation |
| `lib/` | Tool logic and error mapping |
| `hooks/` | React state machine |
| `components/` | Form, result, empty and error UI |
| `__tests__/` | Unit, hook, component and accessibility tests |
| `fixtures/` | Deterministic sample data |
| `msw/` | Request mocks |

## Safety

This tool never accepts an existing secret key. Generated credentials are for
testnet development only and must not be reused or committed.

## Threat boundary

<!-- Required by docs/FEATURE_CONTRACT.md rule 15; verify:features fails while a TODO remains. -->

- **Data sources:** Browser CSPRNG only; no user input, storage, Horizon, or RPC data.
- **Outbound requests:** None — key generation runs entirely in the browser.
- **Cannot sign or submit:** The feature only creates display data and never calls a transaction signing or submission API.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { KeypairGeneratorPanel } from "@/features/keypair-generator/components/KeypairGeneratorPanel";
import { copy } from "@/features/keypair-generator/copy";

describe("KeypairGeneratorPanel", () => {
it("renders the empty state before any input", () => {
renderFeature(<KeypairGeneratorPanel />);
expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
});

it("generates a keypair when submitted", async () => {
const { user } = renderFeature(<KeypairGeneratorPanel />);
await user.click(screen.getByRole("button", { name: copy.submit }));
expect(await screen.findByText(copy.resultTitle)).toBeInTheDocument();
});
});
11 changes: 11 additions & 0 deletions features/keypair-generator/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { renderFeature } from "@/core/testing/render";
import { expectNoAxeViolations } from "@/core/testing/axe";
import { KeypairGeneratorPanel } from "@/features/keypair-generator/components/KeypairGeneratorPanel";

describe("KeypairGeneratorPanel accessibility", () => {
it("has no WCAG A/AA violations in its initial state", async () => {
const { container } = renderFeature(<KeypairGeneratorPanel />);
await expectNoAxeViolations(container);
});
});
21 changes: 21 additions & 0 deletions features/keypair-generator/__tests__/contract.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderFeatureSlice } from "@/core/testing/contract";
import { KeypairGeneratorPanel } from "@/features/keypair-generator/components/KeypairGeneratorPanel";
import { copy } from "@/features/keypair-generator/copy";

describe("keypair generator runtime contract", () => {
it("starts empty and reaches loading then success", async () => {
const slice = renderFeatureSlice("keypair-generator", <KeypairGeneratorPanel />);
slice.expectEmptyState();
await slice.user.click(screen.getByRole("button", { name: copy.submit }));
slice.expectLoadingState();
await slice.waitForState("success");
expect(screen.getByText(copy.resultTitle)).toBeInTheDocument();
});

it.skip("documents the failure contract when browser CSPRNG is unavailable", () => {
const slice = renderFeatureSlice("keypair-generator", <KeypairGeneratorPanel />);
slice.expectErrorState();
});
});
8 changes: 8 additions & 0 deletions features/keypair-generator/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { formatSummary } from "@/features/keypair-generator/lib/format";

describe("formatSummary", () => {
it("trims the value", () => {
expect(formatSummary(" example ")).toBe("example");
});
});
10 changes: 10 additions & 0 deletions features/keypair-generator/__tests__/keypairGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { runKeypairGenerator } from "@/features/keypair-generator/lib/keypairGenerator";

describe("runKeypairGenerator", () => {
it("generates a testnet keypair without network access", async () => {
const result = await runKeypairGenerator({}, "testnet");
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.publicKey).toMatch(/^G/);
});
});
8 changes: 8 additions & 0 deletions features/keypair-generator/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { parseKeypairGeneratorInput } from "@/features/keypair-generator/schema";

describe("parseKeypairGeneratorInput", () => {
it("accepts an empty form because generation has no input", () => {
expect(parseKeypairGeneratorInput(" ")).toEqual({ ok: true, value: {} });
});
});
23 changes: 23 additions & 0 deletions features/keypair-generator/__tests__/useKeypairGenerator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { NetworkProvider } from "@/core/network/NetworkProvider";
import { useKeypairGenerator } from "@/features/keypair-generator/hooks/useKeypairGenerator";

function wrapper({ children }: { children: React.ReactNode }) {
return <NetworkProvider initialNetwork="testnet">{children}</NetworkProvider>;
}

describe("useKeypairGenerator", () => {
it("starts idle", () => {
const { result } = renderHook(() => useKeypairGenerator(), { wrapper });
expect(result.current.state.status).toBe("idle");
});

it("generates a keypair without input", async () => {
const { result } = renderHook(() => useKeypairGenerator(), { wrapper });
await act(async () => {
await result.current.submit("");
});
await waitFor(() => expect(result.current.state.status).toBe("success"));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Sparkles } from "lucide-react";
import { EmptyState } from "@/core/ui/EmptyState";
import { copy } from "@/features/keypair-generator/copy";

export function KeypairGeneratorEmptyState() {
return (
<EmptyState icon={Sparkles} title={copy.emptyTitle} description={copy.emptyDescription} />
);
}
27 changes: 27 additions & 0 deletions features/keypair-generator/components/KeypairGeneratorForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";

import { type FormEvent } from "react";
import { Button } from "@/core/ui/Button";
import { copy } from "@/features/keypair-generator/copy";

export function KeypairGeneratorForm({
onSubmit,
pending
}: {
onSubmit: (value: string) => void;
pending: boolean;
}) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
onSubmit("");
}

return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-slate-600">{copy.formHint}</p>
<Button type="submit" disabled={pending}>
{pending ? "Working..." : copy.submit}
</Button>
</form>
);
}
33 changes: 33 additions & 0 deletions features/keypair-generator/components/KeypairGeneratorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { Card } from "@/core/ui/Card";
import { StatusMessage } from "@/core/ui/StatusMessage";
import { useKeypairGenerator } from "@/features/keypair-generator/hooks/useKeypairGenerator";
import { errorCopy } from "@/features/keypair-generator/copy";
import { KeypairGeneratorForm } from "@/features/keypair-generator/components/KeypairGeneratorForm";
import { KeypairGeneratorResult } from "@/features/keypair-generator/components/KeypairGeneratorResult";
import { KeypairGeneratorEmptyState } from "@/features/keypair-generator/components/KeypairGeneratorEmptyState";

export function KeypairGeneratorPanel() {
const { state, submit } = useKeypairGenerator();

return (
<div className="space-y-5">
<Card>
<KeypairGeneratorForm onSubmit={submit} pending={state.status === "loading"} />
</Card>

{state.status === "error" ? (
<StatusMessage
type="error"
title={errorCopy[state.code].title}
description={errorCopy[state.code].description}
/>
) : null}

{state.status === "success" ? <KeypairGeneratorResult result={state.result} /> : null}

{state.status === "idle" ? <KeypairGeneratorEmptyState /> : null}
</div>
);
}
20 changes: 20 additions & 0 deletions features/keypair-generator/components/KeypairGeneratorResult.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
import { copy } from "@/features/keypair-generator/copy";
import type { KeypairGeneratorResult as KeypairGeneratorResultValue } from "@/features/keypair-generator/types";

export function KeypairGeneratorResult({ result }: { result: KeypairGeneratorResultValue }) {
return (
<Card>
<CardHeader>
<CardTitle>{copy.resultTitle}</CardTitle>
</CardHeader>
<div className="space-y-4 text-sm">
<p className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900">
Testnet only. Treat the secret key like a password: never share it, commit it, or use it on mainnet.
</p>
<p><strong>Public key</strong><br /><code className="break-all">{result.publicKey}</code></p>
<p><strong>Secret key</strong><br /><code className="break-all">{result.secretKey}</code></p>
</div>
</Card>
);
}
17 changes: 17 additions & 0 deletions features/keypair-generator/copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { KeypairGeneratorErrorCode } from "@/features/keypair-generator/types";

export const copy = {
formLabel: "Testnet keypair",
formHint: "Generated locally with your browser's cryptographically secure random source.",
submit: "Generate keypair",
emptyTitle: "No keypair generated",
emptyDescription: "Create a disposable testnet account for development. Never fund or reuse this key outside testnet.",
resultTitle: "Testnet keypair"
} as const;

export const errorCopy: Record<KeypairGeneratorErrorCode, { title: string; description: string }> = {
generation_failed: {
title: "Could not generate a keypair",
description: "Your browser could not provide secure randomness. Try again or use another browser."
}
};
15 changes: 15 additions & 0 deletions features/keypair-generator/e2e/keypair-generator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* End-to-end specification for the Testnet Keypair Generator tool.
*
* Documented as executable steps so the behaviour is reviewable even before a
* browser runner is wired into CI.
*/
export const spec = {
route: "/tools/keypair-generator",
steps: [
{ action: "visit", target: "/tools/keypair-generator" },
{ action: "expect", target: "heading", value: "Testnet Keypair Generator" },
{ action: "click", target: "submit" },
{ action: "expect", target: "alert" }
]
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { KeypairGeneratorResult } from "@/features/keypair-generator/types";

export const keypairGeneratorFixture: KeypairGeneratorResult = {
summary: "example"
};
Loading