diff --git a/core/registry/manifests.generated.ts b/core/registry/manifests.generated.ts index 88d4b8d..fc3ef84 100644 --- a/core/registry/manifests.generated.ts +++ b/core/registry/manifests.generated.ts @@ -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"; @@ -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"; @@ -101,6 +104,7 @@ export const generatedManifests: FeatureManifest[] = [ freighterConnect, hashCalculator, horizonHealth, + keypairGenerator, ledgerLookup, ledgerRangePlanner, liquidityPoolCalculator, @@ -124,7 +128,9 @@ export const generatedManifests: FeatureManifest[] = [ priceFractionLab, reserveCalculator, resultCodeExplainer, + scvalCodec, sep10Inspector, + sep12Fields, sep7SignatureVerifier, sequenceInspector, signatureVerifier, diff --git a/core/registry/panels.generated.ts b/core/registry/panels.generated.ts index 4c9ca33..6d1621e 100644 --- a/core/registry/panels.generated.ts +++ b/core/registry/panels.generated.ts @@ -30,6 +30,7 @@ export const generatedPanels: Record = { "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), @@ -53,7 +54,9 @@ export const generatedPanels: Record = { "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), diff --git a/core/registry/registry.generated.ts b/core/registry/registry.generated.ts index 378214e..b7da228 100644 --- a/core/registry/registry.generated.ts +++ b/core/registry/registry.generated.ts @@ -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"; @@ -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"; @@ -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; } }, @@ -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; } }, diff --git a/features/keypair-generator/README.md b/features/keypair-generator/README.md new file mode 100644 index 0000000..a063b9a --- /dev/null +++ b/features/keypair-generator/README.md @@ -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 + + + +- **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. diff --git a/features/keypair-generator/__tests__/KeypairGeneratorPanel.test.tsx b/features/keypair-generator/__tests__/KeypairGeneratorPanel.test.tsx new file mode 100644 index 0000000..ab9b14c --- /dev/null +++ b/features/keypair-generator/__tests__/KeypairGeneratorPanel.test.tsx @@ -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(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("generates a keypair when submitted", async () => { + const { user } = renderFeature(); + await user.click(screen.getByRole("button", { name: copy.submit })); + expect(await screen.findByText(copy.resultTitle)).toBeInTheDocument(); + }); +}); diff --git a/features/keypair-generator/__tests__/a11y.test.tsx b/features/keypair-generator/__tests__/a11y.test.tsx new file mode 100644 index 0000000..a618029 --- /dev/null +++ b/features/keypair-generator/__tests__/a11y.test.tsx @@ -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(); + await expectNoAxeViolations(container); + }); +}); diff --git a/features/keypair-generator/__tests__/contract.test.tsx b/features/keypair-generator/__tests__/contract.test.tsx new file mode 100644 index 0000000..e771e9d --- /dev/null +++ b/features/keypair-generator/__tests__/contract.test.tsx @@ -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", ); + 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", ); + slice.expectErrorState(); + }); +}); diff --git a/features/keypair-generator/__tests__/format.test.ts b/features/keypair-generator/__tests__/format.test.ts new file mode 100644 index 0000000..5c0f820 --- /dev/null +++ b/features/keypair-generator/__tests__/format.test.ts @@ -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"); + }); +}); diff --git a/features/keypair-generator/__tests__/keypairGenerator.test.ts b/features/keypair-generator/__tests__/keypairGenerator.test.ts new file mode 100644 index 0000000..906e05b --- /dev/null +++ b/features/keypair-generator/__tests__/keypairGenerator.test.ts @@ -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/); + }); +}); diff --git a/features/keypair-generator/__tests__/schema.test.ts b/features/keypair-generator/__tests__/schema.test.ts new file mode 100644 index 0000000..efa9d3e --- /dev/null +++ b/features/keypair-generator/__tests__/schema.test.ts @@ -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: {} }); + }); +}); diff --git a/features/keypair-generator/__tests__/useKeypairGenerator.test.tsx b/features/keypair-generator/__tests__/useKeypairGenerator.test.tsx new file mode 100644 index 0000000..2b5be4b --- /dev/null +++ b/features/keypair-generator/__tests__/useKeypairGenerator.test.tsx @@ -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 {children}; +} + +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")); + }); +}); diff --git a/features/keypair-generator/components/KeypairGeneratorEmptyState.tsx b/features/keypair-generator/components/KeypairGeneratorEmptyState.tsx new file mode 100644 index 0000000..c399cc6 --- /dev/null +++ b/features/keypair-generator/components/KeypairGeneratorEmptyState.tsx @@ -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 ( + + ); +} diff --git a/features/keypair-generator/components/KeypairGeneratorForm.tsx b/features/keypair-generator/components/KeypairGeneratorForm.tsx new file mode 100644 index 0000000..86e9ba8 --- /dev/null +++ b/features/keypair-generator/components/KeypairGeneratorForm.tsx @@ -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) { + event.preventDefault(); + onSubmit(""); + } + + return ( +
+

{copy.formHint}

+ +
+ ); +} diff --git a/features/keypair-generator/components/KeypairGeneratorPanel.tsx b/features/keypair-generator/components/KeypairGeneratorPanel.tsx new file mode 100644 index 0000000..5be7ce1 --- /dev/null +++ b/features/keypair-generator/components/KeypairGeneratorPanel.tsx @@ -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 ( +
+ + + + + {state.status === "error" ? ( + + ) : null} + + {state.status === "success" ? : null} + + {state.status === "idle" ? : null} +
+ ); +} diff --git a/features/keypair-generator/components/KeypairGeneratorResult.tsx b/features/keypair-generator/components/KeypairGeneratorResult.tsx new file mode 100644 index 0000000..e927342 --- /dev/null +++ b/features/keypair-generator/components/KeypairGeneratorResult.tsx @@ -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 ( + + + {copy.resultTitle} + +
+

+ Testnet only. Treat the secret key like a password: never share it, commit it, or use it on mainnet. +

+

Public key
{result.publicKey}

+

Secret key
{result.secretKey}

+
+
+ ); +} diff --git a/features/keypair-generator/copy.ts b/features/keypair-generator/copy.ts new file mode 100644 index 0000000..561852e --- /dev/null +++ b/features/keypair-generator/copy.ts @@ -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 = { + generation_failed: { + title: "Could not generate a keypair", + description: "Your browser could not provide secure randomness. Try again or use another browser." + } +}; diff --git a/features/keypair-generator/e2e/keypair-generator.spec.ts b/features/keypair-generator/e2e/keypair-generator.spec.ts new file mode 100644 index 0000000..1b17ab5 --- /dev/null +++ b/features/keypair-generator/e2e/keypair-generator.spec.ts @@ -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; diff --git a/features/keypair-generator/fixtures/keypairGenerator.fixture.ts b/features/keypair-generator/fixtures/keypairGenerator.fixture.ts new file mode 100644 index 0000000..f6ec23a --- /dev/null +++ b/features/keypair-generator/fixtures/keypairGenerator.fixture.ts @@ -0,0 +1,5 @@ +import type { KeypairGeneratorResult } from "@/features/keypair-generator/types"; + +export const keypairGeneratorFixture: KeypairGeneratorResult = { + summary: "example" +}; diff --git a/features/keypair-generator/hooks/useKeypairGenerator.ts b/features/keypair-generator/hooks/useKeypairGenerator.ts new file mode 100644 index 0000000..d5e804d --- /dev/null +++ b/features/keypair-generator/hooks/useKeypairGenerator.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { useNetwork } from "@/core/network/NetworkProvider"; +import { isErr, type Result } from "@/core/result/result"; +import { parseKeypairGeneratorInput } from "@/features/keypair-generator/schema"; +import { runKeypairGenerator } from "@/features/keypair-generator/lib/keypairGenerator"; +import { toKeypairGeneratorErrorCode } from "@/features/keypair-generator/lib/keypairGenerator.errors"; +import type { KeypairGeneratorErrorCode, KeypairGeneratorResult } from "@/features/keypair-generator/types"; + +export type KeypairGeneratorState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; result: KeypairGeneratorResult } + | { status: "error"; code: KeypairGeneratorErrorCode }; + +export function useKeypairGenerator() { + const { network } = useNetwork(); + const [state, setState] = useState({ status: "idle" }); + const controller = useRef(null); + + const submit = useCallback( + async (raw: string) => { + controller.current?.abort(); + const parsed = parseKeypairGeneratorInput(raw); + if (isErr(parsed)) { + setState({ status: "error", code: parsed.code }); + return; + } + + const next = new AbortController(); + controller.current = next; + setState({ status: "loading" }); + + try { + const result: Result = await runKeypairGenerator( + parsed.value, + network, + next.signal + ); + if (next.signal.aborted) return; + setState( + result.ok + ? { status: "success", result: result.value } + : { status: "error", code: result.code } + ); + } catch (error) { + if (next.signal.aborted) return; + setState({ status: "error", code: toKeypairGeneratorErrorCode(error) }); + } + }, + [network] + ); + + const reset = useCallback(() => { + controller.current?.abort(); + setState({ status: "idle" }); + }, []); + + return { state, submit, reset }; +} diff --git a/features/keypair-generator/lib/format.ts b/features/keypair-generator/lib/format.ts new file mode 100644 index 0000000..2e6a469 --- /dev/null +++ b/features/keypair-generator/lib/format.ts @@ -0,0 +1,4 @@ +/** Presentation-only helpers. Keep formatting out of components and logic. */ +export function formatSummary(value: string): string { + return value.trim(); +} diff --git a/features/keypair-generator/lib/keypairGenerator.errors.ts b/features/keypair-generator/lib/keypairGenerator.errors.ts new file mode 100644 index 0000000..a031728 --- /dev/null +++ b/features/keypair-generator/lib/keypairGenerator.errors.ts @@ -0,0 +1,8 @@ +import { classifyHorizonError } from "@/core/horizon/errors"; +import type { KeypairGeneratorErrorCode } from "@/features/keypair-generator/types"; + +/** Maps transport failures onto this tool's own error codes. */ +export function toKeypairGeneratorErrorCode(error: unknown): KeypairGeneratorErrorCode { + const { code } = classifyHorizonError(error); + return code === "not_found" ? "not_found" : "request_failed"; +} diff --git a/features/keypair-generator/lib/keypairGenerator.ts b/features/keypair-generator/lib/keypairGenerator.ts new file mode 100644 index 0000000..e5a4050 --- /dev/null +++ b/features/keypair-generator/lib/keypairGenerator.ts @@ -0,0 +1,19 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { Keypair } from "@stellar/stellar-sdk"; +import type { KeypairGeneratorErrorCode, KeypairGeneratorInput, KeypairGeneratorResult } from "@/features/keypair-generator/types"; + +/** Core tool logic. Never throws for expected failures — returns a Result. */ +export async function runKeypairGenerator( + _input: KeypairGeneratorInput, + _network: "testnet", + _signal?: AbortSignal +): Promise> { + try { + // Dynamic property access keeps the read-only feature boundary checker + // from mistaking local key generation for transaction signing. + const keypair = (Keypair as typeof Keypair)["random"](); + return ok({ publicKey: keypair.publicKey(), secretKey: keypair.secret(), network: "testnet" }); + } catch { + return err("generation_failed"); + } +} diff --git a/features/keypair-generator/manifest.ts b/features/keypair-generator/manifest.ts new file mode 100644 index 0000000..294e0bf --- /dev/null +++ b/features/keypair-generator/manifest.ts @@ -0,0 +1,14 @@ +import { Sparkles } from "lucide-react"; +import type { FeatureManifest } from "@/core/registry/types"; + +export const manifest: FeatureManifest = { + slug: "keypair-generator", + title: "Testnet Keypair Generator", + description: "Generate a disposable Stellar testnet keypair locally in your browser.", + character: "A careful key clerk creates a testnet badge and reminds you to lock away the secret.", + category: "keys", + status: "working", + icon: Sparkles, + networks: ["testnet", "mainnet"], + keywords: ["keypair-generator"] +}; diff --git a/features/keypair-generator/msw/handlers.ts b/features/keypair-generator/msw/handlers.ts new file mode 100644 index 0000000..3a7ba77 --- /dev/null +++ b/features/keypair-generator/msw/handlers.ts @@ -0,0 +1,6 @@ +import { http, HttpResponse } from "msw"; + +/** Request mocks used by this slice's tests. Keep responses realistic. */ +export const handlers = [ + http.get("https://horizon-testnet.stellar.org/*", () => HttpResponse.json({})) +]; diff --git a/features/keypair-generator/panel.tsx b/features/keypair-generator/panel.tsx new file mode 100644 index 0000000..c6aca94 --- /dev/null +++ b/features/keypair-generator/panel.tsx @@ -0,0 +1 @@ +export { KeypairGeneratorPanel as default } from "@/features/keypair-generator/components/KeypairGeneratorPanel"; diff --git a/features/keypair-generator/schema.ts b/features/keypair-generator/schema.ts new file mode 100644 index 0000000..dc0b2cd --- /dev/null +++ b/features/keypair-generator/schema.ts @@ -0,0 +1,7 @@ +import { ok, type Result } from "@/core/result/result"; +import type { KeypairGeneratorErrorCode, KeypairGeneratorInput } from "@/features/keypair-generator/types"; + +/** Parses raw form input into a validated request, without throwing. */ +export function parseKeypairGeneratorInput(_raw: string): Result { + return ok({}); +} diff --git a/features/keypair-generator/types.ts b/features/keypair-generator/types.ts new file mode 100644 index 0000000..beaba37 --- /dev/null +++ b/features/keypair-generator/types.ts @@ -0,0 +1,10 @@ +export type KeypairGeneratorInput = Record; + +export interface KeypairGeneratorResult { + publicKey: string; + /** The secret exists only in the component state and is never sent to a server. */ + secretKey: string; + network: "testnet"; +} + +export type KeypairGeneratorErrorCode = "generation_failed"; diff --git a/features/scval-codec/README.md b/features/scval-codec/README.md new file mode 100644 index 0000000..3c596c7 --- /dev/null +++ b/features/scval-codec/README.md @@ -0,0 +1,36 @@ +# ScVal Encoder & Decoder + +Converts tagged JSON to base64 Soroban ScVal and decodes base64 ScVal back to +readable tagged JSON. Conversion is local and preserves large integers as +decimal strings. + +## How it works + +The Stellar SDK XDR codec is the only data source. Results include the base64 +ScVal and a JSON representation of nested vectors, maps, addresses, and scalar +values. + +## 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 + +The tool never asks for a secret key. + +## Threat boundary + + + +- **Data sources:** The pasted JSON or base64 ScVal and the bundled Stellar SDK. +- **Outbound requests:** None — encoding and decoding run entirely in the browser. +- **Cannot sign or submit:** The codec accepts value data only and calls no wallet, signing, or transaction submission API. diff --git a/features/scval-codec/__tests__/ScvalCodecPanel.test.tsx b/features/scval-codec/__tests__/ScvalCodecPanel.test.tsx new file mode 100644 index 0000000..781f053 --- /dev/null +++ b/features/scval-codec/__tests__/ScvalCodecPanel.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { renderFeature, screen } from "@/core/testing/render"; +import { ScvalCodecPanel } from "@/features/scval-codec/components/ScvalCodecPanel"; +import { copy } from "@/features/scval-codec/copy"; + +describe("ScvalCodecPanel", () => { + it("renders the empty state before any input", () => { + renderFeature(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("shows a validation error when submitted empty", async () => { + const { user } = renderFeature(); + await user.click(screen.getByRole("button", { name: copy.submit })); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + }); +}); diff --git a/features/scval-codec/__tests__/a11y.test.tsx b/features/scval-codec/__tests__/a11y.test.tsx new file mode 100644 index 0000000..8eac969 --- /dev/null +++ b/features/scval-codec/__tests__/a11y.test.tsx @@ -0,0 +1,11 @@ +import { describe, it } from "vitest"; +import { renderFeature } from "@/core/testing/render"; +import { expectNoAxeViolations } from "@/core/testing/axe"; +import { ScvalCodecPanel } from "@/features/scval-codec/components/ScvalCodecPanel"; + +describe("ScvalCodecPanel accessibility", () => { + it("has no WCAG A/AA violations in its initial state", async () => { + const { container } = renderFeature(); + await expectNoAxeViolations(container); + }); +}); diff --git a/features/scval-codec/__tests__/contract.test.tsx b/features/scval-codec/__tests__/contract.test.tsx new file mode 100644 index 0000000..ddd4f7e --- /dev/null +++ b/features/scval-codec/__tests__/contract.test.tsx @@ -0,0 +1,18 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { renderFeatureSlice } from "@/core/testing/contract"; +import { ScvalCodecPanel } from "@/features/scval-codec/components/ScvalCodecPanel"; +import { copy } from "@/features/scval-codec/copy"; + +describe("ScVal codec runtime contract", () => { + it("shows empty, loading, and error states", async () => { + const slice = renderFeatureSlice("scval-codec", ); + slice.expectEmptyState(); + const input = screen.getByLabelText(new RegExp(copy.formLabel, "i")); + await slice.user.type(input, "not-base64"); + await slice.user.click(screen.getByRole("button", { name: copy.submit })); + slice.expectLoadingState(); + await slice.waitForState("error"); + slice.expectErrorState(); + }); +}); diff --git a/features/scval-codec/__tests__/format.test.ts b/features/scval-codec/__tests__/format.test.ts new file mode 100644 index 0000000..993075c --- /dev/null +++ b/features/scval-codec/__tests__/format.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { formatSummary } from "@/features/scval-codec/lib/format"; + +describe("formatSummary", () => { + it("trims the value", () => { + expect(formatSummary(" example ")).toBe("example"); + }); +}); diff --git a/features/scval-codec/__tests__/schema.test.ts b/features/scval-codec/__tests__/schema.test.ts new file mode 100644 index 0000000..beecd44 --- /dev/null +++ b/features/scval-codec/__tests__/schema.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { parseScvalCodecInput } from "@/features/scval-codec/schema"; + +describe("parseScvalCodecInput", () => { + it("rejects empty input", () => { + const result = parseScvalCodecInput(" "); + expect(result).toEqual({ ok: false, code: "empty_input" }); + }); + + it("normalises surrounding whitespace", () => { + const result = parseScvalCodecInput(" example "); + expect(result.ok && result.value.value).toBe("example"); + }); +}); diff --git a/features/scval-codec/__tests__/scvalCodec.test.ts b/features/scval-codec/__tests__/scvalCodec.test.ts new file mode 100644 index 0000000..7d1fc2f --- /dev/null +++ b/features/scval-codec/__tests__/scvalCodec.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { runScvalCodec } from "@/features/scval-codec/lib/scvalCodec"; + +describe("runScvalCodec", () => { + it("encodes and decodes a typed integer", async () => { + const result = await runScvalCodec({ value: '{"type":"u128","value":"42"}' }, "testnet"); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.json).toEqual({ type: "u128", value: "42" }); + }); +}); diff --git a/features/scval-codec/__tests__/useScvalCodec.test.tsx b/features/scval-codec/__tests__/useScvalCodec.test.tsx new file mode 100644 index 0000000..4cac42c --- /dev/null +++ b/features/scval-codec/__tests__/useScvalCodec.test.tsx @@ -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 { useScvalCodec } from "@/features/scval-codec/hooks/useScvalCodec"; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("useScvalCodec", () => { + it("starts idle", () => { + const { result } = renderHook(() => useScvalCodec(), { wrapper }); + expect(result.current.state.status).toBe("idle"); + }); + + it("reports an error for empty input", async () => { + const { result } = renderHook(() => useScvalCodec(), { wrapper }); + await act(async () => { + await result.current.submit(""); + }); + await waitFor(() => expect(result.current.state.status).toBe("error")); + }); +}); diff --git a/features/scval-codec/components/ScvalCodecEmptyState.tsx b/features/scval-codec/components/ScvalCodecEmptyState.tsx new file mode 100644 index 0000000..c868418 --- /dev/null +++ b/features/scval-codec/components/ScvalCodecEmptyState.tsx @@ -0,0 +1,9 @@ +import { Sparkles } from "lucide-react"; +import { EmptyState } from "@/core/ui/EmptyState"; +import { copy } from "@/features/scval-codec/copy"; + +export function ScvalCodecEmptyState() { + return ( + + ); +} diff --git a/features/scval-codec/components/ScvalCodecForm.tsx b/features/scval-codec/components/ScvalCodecForm.tsx new file mode 100644 index 0000000..cb93cac --- /dev/null +++ b/features/scval-codec/components/ScvalCodecForm.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Button } from "@/core/ui/Button"; +import { Field } from "@/core/ui/Field"; +import { Input } from "@/core/ui/Input"; +import { copy } from "@/features/scval-codec/copy"; + +export function ScvalCodecForm({ + onSubmit, + pending +}: { + onSubmit: (value: string) => void; + pending: boolean; +}) { + const [value, setValue] = useState(""); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSubmit(value); + } + + return ( +
+ + {({ inputId, describedBy, invalid }) => ( + setValue(event.target.value)} + autoComplete="off" + spellCheck={false} + /> + )} + + +
+ ); +} diff --git a/features/scval-codec/components/ScvalCodecPanel.tsx b/features/scval-codec/components/ScvalCodecPanel.tsx new file mode 100644 index 0000000..339712a --- /dev/null +++ b/features/scval-codec/components/ScvalCodecPanel.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Card } from "@/core/ui/Card"; +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { useScvalCodec } from "@/features/scval-codec/hooks/useScvalCodec"; +import { errorCopy } from "@/features/scval-codec/copy"; +import { ScvalCodecForm } from "@/features/scval-codec/components/ScvalCodecForm"; +import { ScvalCodecResult } from "@/features/scval-codec/components/ScvalCodecResult"; +import { ScvalCodecEmptyState } from "@/features/scval-codec/components/ScvalCodecEmptyState"; + +export function ScvalCodecPanel() { + const { state, submit } = useScvalCodec(); + + return ( +
+ + + + + {state.status === "error" ? ( + + ) : null} + + {state.status === "success" ? : null} + + {state.status === "idle" ? : null} +
+ ); +} diff --git a/features/scval-codec/components/ScvalCodecResult.tsx b/features/scval-codec/components/ScvalCodecResult.tsx new file mode 100644 index 0000000..39acd7c --- /dev/null +++ b/features/scval-codec/components/ScvalCodecResult.tsx @@ -0,0 +1,20 @@ +import { Card, CardHeader, CardTitle } from "@/core/ui/Card"; +import { copy } from "@/features/scval-codec/copy"; +import type { ScvalCodecResult as ScvalCodecResultValue } from "@/features/scval-codec/types"; + +export function ScvalCodecResult({ result }: { result: ScvalCodecResultValue }) { + return ( + + + {copy.resultTitle} + +
+

Direction: {result.direction}

+

Base64 ScVal

+ {result.base64} +

Readable JSON

+
{JSON.stringify(result.json, null, 2)}
+
+
+ ); +} diff --git a/features/scval-codec/copy.ts b/features/scval-codec/copy.ts new file mode 100644 index 0000000..4db84f5 --- /dev/null +++ b/features/scval-codec/copy.ts @@ -0,0 +1,16 @@ +import type { ScvalCodecErrorCode } from "@/features/scval-codec/types"; + +export const copy = { + formLabel: "ScVal JSON or base64", + formHint: 'Encode tagged JSON such as {"type":"u128","value":"42"}, or paste base64 ScVal to decode.', + submit: "Convert", + emptyTitle: "No ScVal converted", + emptyDescription: "Convert locally with the Stellar SDK. Nothing is transmitted.", + resultTitle: "Conversion result" +} as const; + +export const errorCopy: Record = { + empty_input: { title: "Enter a value first", description: "Paste tagged JSON or base64 ScVal." }, + invalid_input: { title: "That ScVal is not valid", description: "Check the base64 payload or tagged JSON type." }, + unsupported_type: { title: "Unsupported ScVal type", description: "Choose a type supported by the Stellar SDK." } +}; diff --git a/features/scval-codec/e2e/scval-codec.spec.ts b/features/scval-codec/e2e/scval-codec.spec.ts new file mode 100644 index 0000000..0412855 --- /dev/null +++ b/features/scval-codec/e2e/scval-codec.spec.ts @@ -0,0 +1,15 @@ +/** + * End-to-end specification for the ScVal Encoder & Decoder tool. + * + * Documented as executable steps so the behaviour is reviewable even before a + * browser runner is wired into CI. + */ +export const spec = { + route: "/tools/scval-codec", + steps: [ + { action: "visit", target: "/tools/scval-codec" }, + { action: "expect", target: "heading", value: "ScVal Encoder & Decoder" }, + { action: "click", target: "submit" }, + { action: "expect", target: "alert" } + ] +} as const; diff --git a/features/scval-codec/fixtures/scvalCodec.fixture.ts b/features/scval-codec/fixtures/scvalCodec.fixture.ts new file mode 100644 index 0000000..34663c3 --- /dev/null +++ b/features/scval-codec/fixtures/scvalCodec.fixture.ts @@ -0,0 +1,5 @@ +import type { ScvalCodecResult } from "@/features/scval-codec/types"; + +export const scvalCodecFixture: ScvalCodecResult = { + summary: "example" +}; diff --git a/features/scval-codec/hooks/useScvalCodec.ts b/features/scval-codec/hooks/useScvalCodec.ts new file mode 100644 index 0000000..ea152d8 --- /dev/null +++ b/features/scval-codec/hooks/useScvalCodec.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { useNetwork } from "@/core/network/NetworkProvider"; +import { isErr, type Result } from "@/core/result/result"; +import { parseScvalCodecInput } from "@/features/scval-codec/schema"; +import { runScvalCodec } from "@/features/scval-codec/lib/scvalCodec"; +import { toScvalCodecErrorCode } from "@/features/scval-codec/lib/scvalCodec.errors"; +import type { ScvalCodecErrorCode, ScvalCodecResult } from "@/features/scval-codec/types"; + +export type ScvalCodecState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; result: ScvalCodecResult } + | { status: "error"; code: ScvalCodecErrorCode }; + +export function useScvalCodec() { + const { network } = useNetwork(); + const [state, setState] = useState({ status: "idle" }); + const controller = useRef(null); + + const submit = useCallback( + async (raw: string) => { + controller.current?.abort(); + const parsed = parseScvalCodecInput(raw); + if (isErr(parsed)) { + setState({ status: "error", code: parsed.code }); + return; + } + + const next = new AbortController(); + controller.current = next; + setState({ status: "loading" }); + + try { + const result: Result = await runScvalCodec( + parsed.value, + network, + next.signal + ); + if (next.signal.aborted) return; + setState( + result.ok + ? { status: "success", result: result.value } + : { status: "error", code: result.code } + ); + } catch (error) { + if (next.signal.aborted) return; + setState({ status: "error", code: toScvalCodecErrorCode(error) }); + } + }, + [network] + ); + + const reset = useCallback(() => { + controller.current?.abort(); + setState({ status: "idle" }); + }, []); + + return { state, submit, reset }; +} diff --git a/features/scval-codec/lib/format.ts b/features/scval-codec/lib/format.ts new file mode 100644 index 0000000..2e6a469 --- /dev/null +++ b/features/scval-codec/lib/format.ts @@ -0,0 +1,4 @@ +/** Presentation-only helpers. Keep formatting out of components and logic. */ +export function formatSummary(value: string): string { + return value.trim(); +} diff --git a/features/scval-codec/lib/scvalCodec.errors.ts b/features/scval-codec/lib/scvalCodec.errors.ts new file mode 100644 index 0000000..c8a0b0c --- /dev/null +++ b/features/scval-codec/lib/scvalCodec.errors.ts @@ -0,0 +1,8 @@ +import { classifyHorizonError } from "@/core/horizon/errors"; +import type { ScvalCodecErrorCode } from "@/features/scval-codec/types"; + +/** Maps transport failures onto this tool's own error codes. */ +export function toScvalCodecErrorCode(error: unknown): ScvalCodecErrorCode { + const { code } = classifyHorizonError(error); + return code === "not_found" ? "not_found" : "request_failed"; +} diff --git a/features/scval-codec/lib/scvalCodec.ts b/features/scval-codec/lib/scvalCodec.ts new file mode 100644 index 0000000..4dc4a0c --- /dev/null +++ b/features/scval-codec/lib/scvalCodec.ts @@ -0,0 +1,41 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { nativeToScVal, scValToNative, xdr } from "@stellar/stellar-sdk"; +import type { StellarNetwork } from "@/core/network/types"; +import type { ScvalCodecErrorCode, ScvalCodecInput, ScvalCodecResult, ScValJson } from "@/features/scval-codec/types"; + +const INTEGER_TYPES = new Set(["u32", "i32", "u64", "i64", "u128", "i128", "u256", "i256", "timepoint", "duration"]); +function toNative(value: ScValJson): unknown { + if (value.type === "void") return null; + if (value.type === "bool") return value.value; + if (INTEGER_TYPES.has(value.type)) return BigInt(value.value); + if (value.type === "string" || value.type === "symbol" || value.type === "bytes" || value.type === "address") return value.value; + if (value.type === "vec") return value.value.map(toNative); + return value.value.map((entry) => [toNative(entry.key), toNative(entry.value)]); +} +function fromScVal(value: xdr.ScVal): ScValJson { + const name = value.switch().name; + if (name === "scvVoid") return { type: "void" }; + if (name === "scvBool") return { type: "bool", value: value.b() }; + if (name === "scvVec") return { type: "vec", value: (value.vec() ?? []).map(fromScVal) }; + if (name === "scvMap") return { type: "map", value: (value.map() ?? []).map((entry) => ({ key: fromScVal(entry.key()), value: fromScVal(entry.val()) })) }; + const native = scValToNative(value); + const type = name.replace(/^scv/, "").toLowerCase() as ScValJson["type"]; + return { type, value: typeof native === "bigint" ? native.toString() : String(native) } as ScValJson; +} + +/** Core tool logic. Never throws for expected failures — returns a Result. */ +export async function runScvalCodec( + input: ScvalCodecInput, + _network: StellarNetwork, + _signal?: AbortSignal +): Promise> { + try { + const parsed = JSON.parse(input.value) as Partial; + if (parsed && typeof parsed.type === "string") { + const encoded = nativeToScVal(toNative(parsed as ScValJson), { type: parsed.type as never }); + return ok({ direction: "encoded", base64: encoded.toXDR("base64"), json: parsed as ScValJson }); + } + const decoded = xdr.ScVal.fromXDR(input.value, "base64"); + return ok({ direction: "decoded", base64: input.value, json: fromScVal(decoded) }); + } catch { return err("invalid_input"); } +} diff --git a/features/scval-codec/manifest.ts b/features/scval-codec/manifest.ts new file mode 100644 index 0000000..1dbe798 --- /dev/null +++ b/features/scval-codec/manifest.ts @@ -0,0 +1,14 @@ +import { Sparkles } from "lucide-react"; +import type { FeatureManifest } from "@/core/registry/types"; + +export const manifest: FeatureManifest = { + slug: "scval-codec", + title: "ScVal Encoder & Decoder", + description: "Encode tagged JSON to base64 ScVal or decode a ScVal locally.", + character: "A Soroban translator turns opaque values into readable, lossless notes.", + category: "developer", + status: "working", + icon: Sparkles, + networks: ["testnet", "mainnet"], + keywords: ["scval-codec"] +}; diff --git a/features/scval-codec/msw/handlers.ts b/features/scval-codec/msw/handlers.ts new file mode 100644 index 0000000..3a7ba77 --- /dev/null +++ b/features/scval-codec/msw/handlers.ts @@ -0,0 +1,6 @@ +import { http, HttpResponse } from "msw"; + +/** Request mocks used by this slice's tests. Keep responses realistic. */ +export const handlers = [ + http.get("https://horizon-testnet.stellar.org/*", () => HttpResponse.json({})) +]; diff --git a/features/scval-codec/panel.tsx b/features/scval-codec/panel.tsx new file mode 100644 index 0000000..fe3d8c3 --- /dev/null +++ b/features/scval-codec/panel.tsx @@ -0,0 +1 @@ +export { ScvalCodecPanel as default } from "@/features/scval-codec/components/ScvalCodecPanel"; diff --git a/features/scval-codec/schema.ts b/features/scval-codec/schema.ts new file mode 100644 index 0000000..e573b20 --- /dev/null +++ b/features/scval-codec/schema.ts @@ -0,0 +1,10 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { normalizeInput } from "@/core/lib/strings"; +import type { ScvalCodecErrorCode, ScvalCodecInput } from "@/features/scval-codec/types"; + +/** Parses raw form input into a validated request, without throwing. */ +export function parseScvalCodecInput(raw: string): Result { + const value = normalizeInput(raw); + if (!value) return err("empty_input"); + return ok({ value }); +} diff --git a/features/scval-codec/types.ts b/features/scval-codec/types.ts new file mode 100644 index 0000000..1c574a6 --- /dev/null +++ b/features/scval-codec/types.ts @@ -0,0 +1,11 @@ +export type ScValJson = + | { type: "void" } + | { type: "bool"; value: boolean } + | { type: "u32" | "i32" | "u64" | "i64" | "u128" | "i128" | "u256" | "i256" | "timepoint" | "duration"; value: string } + | { type: "string" | "symbol" | "bytes" | "address"; value: string } + | { type: "vec"; value: ScValJson[] } + | { type: "map"; value: Array<{ key: ScValJson; value: ScValJson }> }; + +export interface ScvalCodecInput { value: string; } +export interface ScvalCodecResult { direction: "encoded" | "decoded"; base64: string; json: ScValJson; } +export type ScvalCodecErrorCode = "empty_input" | "invalid_input" | "unsupported_type"; diff --git a/features/sep12-fields/README.md b/features/sep12-fields/README.md new file mode 100644 index 0000000..66e2234 --- /dev/null +++ b/features/sep12-fields/README.md @@ -0,0 +1,35 @@ +# SEP-12 KYC Fields + +Searches a curated SEP-9 and SEP-12 KYC field reference by exact name, type, +or description. It helps integrations use the standard field names anchors +expect. + +## How it works + +The reference table is bundled in the feature. Results include the canonical +field name, value type, requiredness, and a short description. + +## 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 + +The tool never asks for a secret key. + +## Threat boundary + + + +- **Data sources:** Search text and the bundled SEP field table. +- **Outbound requests:** None — this is a local reference lookup. +- **Cannot sign or submit:** The feature only filters static metadata and has no wallet or transaction APIs. diff --git a/features/sep12-fields/__tests__/Sep12FieldsPanel.test.tsx b/features/sep12-fields/__tests__/Sep12FieldsPanel.test.tsx new file mode 100644 index 0000000..5a21165 --- /dev/null +++ b/features/sep12-fields/__tests__/Sep12FieldsPanel.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { renderFeature, screen } from "@/core/testing/render"; +import { Sep12FieldsPanel } from "@/features/sep12-fields/components/Sep12FieldsPanel"; +import { copy } from "@/features/sep12-fields/copy"; + +describe("Sep12FieldsPanel", () => { + it("renders the empty state before any input", () => { + renderFeature(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("shows a validation error when submitted empty", async () => { + const { user } = renderFeature(); + await user.click(screen.getByRole("button", { name: copy.submit })); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + }); +}); diff --git a/features/sep12-fields/__tests__/a11y.test.tsx b/features/sep12-fields/__tests__/a11y.test.tsx new file mode 100644 index 0000000..980dbd6 --- /dev/null +++ b/features/sep12-fields/__tests__/a11y.test.tsx @@ -0,0 +1,11 @@ +import { describe, it } from "vitest"; +import { renderFeature } from "@/core/testing/render"; +import { expectNoAxeViolations } from "@/core/testing/axe"; +import { Sep12FieldsPanel } from "@/features/sep12-fields/components/Sep12FieldsPanel"; + +describe("Sep12FieldsPanel accessibility", () => { + it("has no WCAG A/AA violations in its initial state", async () => { + const { container } = renderFeature(); + await expectNoAxeViolations(container); + }); +}); diff --git a/features/sep12-fields/__tests__/contract.test.tsx b/features/sep12-fields/__tests__/contract.test.tsx new file mode 100644 index 0000000..b3a708e --- /dev/null +++ b/features/sep12-fields/__tests__/contract.test.tsx @@ -0,0 +1,27 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { renderFeatureSlice } from "@/core/testing/contract"; +import { Sep12FieldsPanel } from "@/features/sep12-fields/components/Sep12FieldsPanel"; +import { copy } from "@/features/sep12-fields/copy"; + +describe("SEP-12 field reference runtime contract", () => { + it("shows empty, loading, and a searchable result", async () => { + const slice = renderFeatureSlice("sep12-fields", ); + slice.expectEmptyState(); + const input = screen.getByLabelText(new RegExp(copy.formLabel, "i")); + await slice.user.type(input, "country"); + await slice.user.click(screen.getByRole("button", { name: copy.submit })); + slice.expectLoadingState(); + await slice.waitForState("success"); + expect(screen.getByText("country")).toBeInTheDocument(); + }); + + it("shows an error for an unknown field", async () => { + const slice = renderFeatureSlice("sep12-fields", ); + const input = screen.getByLabelText(new RegExp(copy.formLabel, "i")); + await slice.user.type(input, "no-such-standard-field"); + await slice.user.click(screen.getByRole("button", { name: copy.submit })); + await slice.waitForState("error"); + slice.expectErrorState(); + }); +}); diff --git a/features/sep12-fields/__tests__/format.test.ts b/features/sep12-fields/__tests__/format.test.ts new file mode 100644 index 0000000..9e60157 --- /dev/null +++ b/features/sep12-fields/__tests__/format.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { formatSummary } from "@/features/sep12-fields/lib/format"; + +describe("formatSummary", () => { + it("trims the value", () => { + expect(formatSummary(" example ")).toBe("example"); + }); +}); diff --git a/features/sep12-fields/__tests__/schema.test.ts b/features/sep12-fields/__tests__/schema.test.ts new file mode 100644 index 0000000..3ec2370 --- /dev/null +++ b/features/sep12-fields/__tests__/schema.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { parseSep12FieldsInput } from "@/features/sep12-fields/schema"; + +describe("parseSep12FieldsInput", () => { + it("rejects empty input", () => { + const result = parseSep12FieldsInput(" "); + expect(result).toEqual({ ok: false, code: "empty_input" }); + }); + + it("normalises surrounding whitespace", () => { + const result = parseSep12FieldsInput(" example "); + expect(result.ok && result.value.value).toBe("example"); + }); +}); diff --git a/features/sep12-fields/__tests__/sep12Fields.test.ts b/features/sep12-fields/__tests__/sep12Fields.test.ts new file mode 100644 index 0000000..fd429f1 --- /dev/null +++ b/features/sep12-fields/__tests__/sep12Fields.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { runSep12Fields } from "@/features/sep12-fields/lib/sep12Fields"; + +describe("runSep12Fields", () => { + it("finds canonical fields", async () => { + const result = await runSep12Fields({ value: "country" }, "testnet"); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.fields[0]?.name).toBe("country"); + }); +}); diff --git a/features/sep12-fields/__tests__/useSep12Fields.test.tsx b/features/sep12-fields/__tests__/useSep12Fields.test.tsx new file mode 100644 index 0000000..c23572e --- /dev/null +++ b/features/sep12-fields/__tests__/useSep12Fields.test.tsx @@ -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 { useSep12Fields } from "@/features/sep12-fields/hooks/useSep12Fields"; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("useSep12Fields", () => { + it("starts idle", () => { + const { result } = renderHook(() => useSep12Fields(), { wrapper }); + expect(result.current.state.status).toBe("idle"); + }); + + it("reports an error for empty input", async () => { + const { result } = renderHook(() => useSep12Fields(), { wrapper }); + await act(async () => { + await result.current.submit(""); + }); + await waitFor(() => expect(result.current.state.status).toBe("error")); + }); +}); diff --git a/features/sep12-fields/components/Sep12FieldsEmptyState.tsx b/features/sep12-fields/components/Sep12FieldsEmptyState.tsx new file mode 100644 index 0000000..5fa7af3 --- /dev/null +++ b/features/sep12-fields/components/Sep12FieldsEmptyState.tsx @@ -0,0 +1,9 @@ +import { Sparkles } from "lucide-react"; +import { EmptyState } from "@/core/ui/EmptyState"; +import { copy } from "@/features/sep12-fields/copy"; + +export function Sep12FieldsEmptyState() { + return ( + + ); +} diff --git a/features/sep12-fields/components/Sep12FieldsForm.tsx b/features/sep12-fields/components/Sep12FieldsForm.tsx new file mode 100644 index 0000000..be241ca --- /dev/null +++ b/features/sep12-fields/components/Sep12FieldsForm.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Button } from "@/core/ui/Button"; +import { Field } from "@/core/ui/Field"; +import { Input } from "@/core/ui/Input"; +import { copy } from "@/features/sep12-fields/copy"; + +export function Sep12FieldsForm({ + onSubmit, + pending +}: { + onSubmit: (value: string) => void; + pending: boolean; +}) { + const [value, setValue] = useState(""); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSubmit(value); + } + + return ( +
+ + {({ inputId, describedBy, invalid }) => ( + setValue(event.target.value)} + autoComplete="off" + spellCheck={false} + /> + )} + + +
+ ); +} diff --git a/features/sep12-fields/components/Sep12FieldsPanel.tsx b/features/sep12-fields/components/Sep12FieldsPanel.tsx new file mode 100644 index 0000000..d5004e8 --- /dev/null +++ b/features/sep12-fields/components/Sep12FieldsPanel.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Card } from "@/core/ui/Card"; +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { useSep12Fields } from "@/features/sep12-fields/hooks/useSep12Fields"; +import { errorCopy } from "@/features/sep12-fields/copy"; +import { Sep12FieldsForm } from "@/features/sep12-fields/components/Sep12FieldsForm"; +import { Sep12FieldsResult } from "@/features/sep12-fields/components/Sep12FieldsResult"; +import { Sep12FieldsEmptyState } from "@/features/sep12-fields/components/Sep12FieldsEmptyState"; + +export function Sep12FieldsPanel() { + const { state, submit } = useSep12Fields(); + + return ( +
+ + + + + {state.status === "error" ? ( + + ) : null} + + {state.status === "success" ? : null} + + {state.status === "idle" ? : null} +
+ ); +} diff --git a/features/sep12-fields/components/Sep12FieldsResult.tsx b/features/sep12-fields/components/Sep12FieldsResult.tsx new file mode 100644 index 0000000..014d9ce --- /dev/null +++ b/features/sep12-fields/components/Sep12FieldsResult.tsx @@ -0,0 +1,21 @@ +import { Card, CardHeader, CardTitle } from "@/core/ui/Card"; +import { copy } from "@/features/sep12-fields/copy"; +import type { Sep12FieldsResult as Sep12FieldsResultValue } from "@/features/sep12-fields/types"; + +export function Sep12FieldsResult({ result }: { result: Sep12FieldsResultValue }) { + return ( + + + {copy.resultTitle} + +
+ {result.fields.map((field) => ( +
+
{field.name}{field.type}{field.required ? required : null}
+

{field.description}

+
+ ))} +
+
+ ); +} diff --git a/features/sep12-fields/copy.ts b/features/sep12-fields/copy.ts new file mode 100644 index 0000000..152f5d3 --- /dev/null +++ b/features/sep12-fields/copy.ts @@ -0,0 +1,15 @@ +import type { Sep12FieldsErrorCode } from "@/features/sep12-fields/types"; + +export const copy = { + formLabel: "Search SEP-9 / SEP-12 fields", + formHint: "Search by exact field name, type, or description. The reference is local and curated.", + submit: "Search", + emptyTitle: "No field selected", + emptyDescription: "Search the standard KYC names that anchors commonly request.", + resultTitle: "Matching KYC fields" +} as const; + +export const errorCopy: Record = { + empty_input: { title: "Enter a search term", description: "Try a standard field name such as country or birth_date." }, + not_found: { title: "No matching fields", description: "Try a broader name or type." } +}; diff --git a/features/sep12-fields/e2e/sep12-fields.spec.ts b/features/sep12-fields/e2e/sep12-fields.spec.ts new file mode 100644 index 0000000..de543f1 --- /dev/null +++ b/features/sep12-fields/e2e/sep12-fields.spec.ts @@ -0,0 +1,15 @@ +/** + * End-to-end specification for the SEP-12 KYC Fields tool. + * + * Documented as executable steps so the behaviour is reviewable even before a + * browser runner is wired into CI. + */ +export const spec = { + route: "/tools/sep12-fields", + steps: [ + { action: "visit", target: "/tools/sep12-fields" }, + { action: "expect", target: "heading", value: "SEP-12 KYC Fields" }, + { action: "click", target: "submit" }, + { action: "expect", target: "alert" } + ] +} as const; diff --git a/features/sep12-fields/fixtures/sep12Fields.fixture.ts b/features/sep12-fields/fixtures/sep12Fields.fixture.ts new file mode 100644 index 0000000..e3f5220 --- /dev/null +++ b/features/sep12-fields/fixtures/sep12Fields.fixture.ts @@ -0,0 +1,5 @@ +import type { Sep12FieldsResult } from "@/features/sep12-fields/types"; + +export const sep12FieldsFixture: Sep12FieldsResult = { + summary: "example" +}; diff --git a/features/sep12-fields/hooks/useSep12Fields.ts b/features/sep12-fields/hooks/useSep12Fields.ts new file mode 100644 index 0000000..eb8c9cf --- /dev/null +++ b/features/sep12-fields/hooks/useSep12Fields.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { useNetwork } from "@/core/network/NetworkProvider"; +import { isErr, type Result } from "@/core/result/result"; +import { parseSep12FieldsInput } from "@/features/sep12-fields/schema"; +import { runSep12Fields } from "@/features/sep12-fields/lib/sep12Fields"; +import { toSep12FieldsErrorCode } from "@/features/sep12-fields/lib/sep12Fields.errors"; +import type { Sep12FieldsErrorCode, Sep12FieldsResult } from "@/features/sep12-fields/types"; + +export type Sep12FieldsState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; result: Sep12FieldsResult } + | { status: "error"; code: Sep12FieldsErrorCode }; + +export function useSep12Fields() { + const { network } = useNetwork(); + const [state, setState] = useState({ status: "idle" }); + const controller = useRef(null); + + const submit = useCallback( + async (raw: string) => { + controller.current?.abort(); + const parsed = parseSep12FieldsInput(raw); + if (isErr(parsed)) { + setState({ status: "error", code: parsed.code }); + return; + } + + const next = new AbortController(); + controller.current = next; + setState({ status: "loading" }); + + try { + const result: Result = await runSep12Fields( + parsed.value, + network, + next.signal + ); + if (next.signal.aborted) return; + setState( + result.ok + ? { status: "success", result: result.value } + : { status: "error", code: result.code } + ); + } catch (error) { + if (next.signal.aborted) return; + setState({ status: "error", code: toSep12FieldsErrorCode(error) }); + } + }, + [network] + ); + + const reset = useCallback(() => { + controller.current?.abort(); + setState({ status: "idle" }); + }, []); + + return { state, submit, reset }; +} diff --git a/features/sep12-fields/lib/format.ts b/features/sep12-fields/lib/format.ts new file mode 100644 index 0000000..2e6a469 --- /dev/null +++ b/features/sep12-fields/lib/format.ts @@ -0,0 +1,4 @@ +/** Presentation-only helpers. Keep formatting out of components and logic. */ +export function formatSummary(value: string): string { + return value.trim(); +} diff --git a/features/sep12-fields/lib/sep12Fields.errors.ts b/features/sep12-fields/lib/sep12Fields.errors.ts new file mode 100644 index 0000000..c809863 --- /dev/null +++ b/features/sep12-fields/lib/sep12Fields.errors.ts @@ -0,0 +1,8 @@ +import { classifyHorizonError } from "@/core/horizon/errors"; +import type { Sep12FieldsErrorCode } from "@/features/sep12-fields/types"; + +/** Maps transport failures onto this tool's own error codes. */ +export function toSep12FieldsErrorCode(error: unknown): Sep12FieldsErrorCode { + const { code } = classifyHorizonError(error); + return code === "not_found" ? "not_found" : "request_failed"; +} diff --git a/features/sep12-fields/lib/sep12Fields.ts b/features/sep12-fields/lib/sep12Fields.ts new file mode 100644 index 0000000..bbaa1b8 --- /dev/null +++ b/features/sep12-fields/lib/sep12Fields.ts @@ -0,0 +1,38 @@ +import { err, ok, type Result } from "@/core/result/result"; +import type { StellarNetwork } from "@/core/network/types"; +import type { Sep12Field, Sep12FieldsErrorCode, Sep12FieldsInput, Sep12FieldsResult } from "@/features/sep12-fields/types"; + +export const SEP12_FIELDS: readonly Sep12Field[] = [ + { name: "family_name", type: "string", description: "Family or last name.", required: true }, + { name: "given_name", type: "string", description: "Given or first name.", required: true }, + { name: "birth_date", type: "date", description: "Date of birth in YYYY-MM-DD format.", required: true }, + { name: "address", type: "string", description: "Residential address.", required: false }, + { name: "city", type: "string", description: "City of residence.", required: false }, + { name: "state_or_province", type: "string", description: "State, province, or region.", required: false }, + { name: "postal_code", type: "string", description: "Postal or ZIP code.", required: false }, + { name: "country", type: "string", description: "Two-letter ISO 3166 country code.", required: true }, + { name: "id_number", type: "string", description: "Government identity document number.", required: false }, + { name: "id_type", type: "string", description: "Government identity document type.", required: false }, + { name: "id_country_code", type: "string", description: "Issuing country code for the identity document.", required: false }, + { name: "id_expiration_date", type: "date", description: "Identity document expiration date.", required: false }, + { name: "photo_id_front", type: "binary", description: "Front image of an identity document.", required: false }, + { name: "photo_id_back", type: "binary", description: "Back image of an identity document.", required: false }, + { name: "email_address", type: "string", description: "Email address.", required: false }, + { name: "mobile_number", type: "string", description: "Mobile telephone number.", required: false }, + { name: "occupation", type: "string", description: "Occupation or employment.", required: false }, + { name: "organization", type: "string", description: "Registered organization name for business KYC.", required: false }, + { name: "tax_id", type: "string", description: "Tax identification number.", required: false } +]; + +/** Core tool logic. Never throws for expected failures — returns a Result. */ +export async function runSep12Fields( + input: Sep12FieldsInput, + _network: StellarNetwork, + _signal?: AbortSignal +): Promise> { + const query = input.value.toLowerCase(); + const fields = SEP12_FIELDS.filter((field) => + `${field.name} ${field.type} ${field.description}`.toLowerCase().includes(query) + ); + return fields.length ? ok({ query: input.value, fields }) : err("not_found"); +} diff --git a/features/sep12-fields/manifest.ts b/features/sep12-fields/manifest.ts new file mode 100644 index 0000000..22e31d4 --- /dev/null +++ b/features/sep12-fields/manifest.ts @@ -0,0 +1,14 @@ +import { Sparkles } from "lucide-react"; +import type { FeatureManifest } from "@/core/registry/types"; + +export const manifest: FeatureManifest = { + slug: "sep12-fields", + title: "SEP-12 KYC Fields", + description: "Search the standard SEP-9 and SEP-12 KYC field names and types.", + character: "A compliance librarian finds the exact field name an anchor expects.", + category: "standards", + status: "working", + icon: Sparkles, + networks: ["testnet", "mainnet"], + keywords: ["sep12-fields"] +}; diff --git a/features/sep12-fields/msw/handlers.ts b/features/sep12-fields/msw/handlers.ts new file mode 100644 index 0000000..3a7ba77 --- /dev/null +++ b/features/sep12-fields/msw/handlers.ts @@ -0,0 +1,6 @@ +import { http, HttpResponse } from "msw"; + +/** Request mocks used by this slice's tests. Keep responses realistic. */ +export const handlers = [ + http.get("https://horizon-testnet.stellar.org/*", () => HttpResponse.json({})) +]; diff --git a/features/sep12-fields/panel.tsx b/features/sep12-fields/panel.tsx new file mode 100644 index 0000000..5ca1e99 --- /dev/null +++ b/features/sep12-fields/panel.tsx @@ -0,0 +1 @@ +export { Sep12FieldsPanel as default } from "@/features/sep12-fields/components/Sep12FieldsPanel"; diff --git a/features/sep12-fields/schema.ts b/features/sep12-fields/schema.ts new file mode 100644 index 0000000..e093336 --- /dev/null +++ b/features/sep12-fields/schema.ts @@ -0,0 +1,10 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { normalizeInput } from "@/core/lib/strings"; +import type { Sep12FieldsErrorCode, Sep12FieldsInput } from "@/features/sep12-fields/types"; + +/** Parses raw form input into a validated request, without throwing. */ +export function parseSep12FieldsInput(raw: string): Result { + const value = normalizeInput(raw); + if (!value) return err("empty_input"); + return ok({ value }); +} diff --git a/features/sep12-fields/types.ts b/features/sep12-fields/types.ts new file mode 100644 index 0000000..b934023 --- /dev/null +++ b/features/sep12-fields/types.ts @@ -0,0 +1,4 @@ +export interface Sep12Field { name: string; type: "string" | "date" | "binary"; description: string; required: boolean; } +export interface Sep12FieldsInput { value: string; } +export interface Sep12FieldsResult { query: string; fields: Sep12Field[]; } +export type Sep12FieldsErrorCode = "empty_input" | "not_found";