diff --git a/features/duplicate-transaction-scanner/__tests__/fingerprint.test.ts b/features/duplicate-transaction-scanner/__tests__/fingerprint.test.ts
new file mode 100644
index 0000000..61b1e0f
--- /dev/null
+++ b/features/duplicate-transaction-scanner/__tests__/fingerprint.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeFingerprint,
+ groupByFingerprint,
+ TIME_BUCKET_SECONDS,
+ timeBucketFor,
+} from "@/features/duplicate-transaction-scanner/lib/fingerprint";
+import {
+ TX_A1,
+ TX_A2,
+ TX_B1,
+} from "@/features/duplicate-transaction-scanner/fixtures/scanner.fixture";
+
+describe("timeBucketFor", () => {
+ it("returns the same bucket for two timestamps in the same 5-minute window", () => {
+ const t1 = "2026-10-18T22:05:30.000Z";
+ const t2 = "2026-10-18T22:06:00.000Z";
+ expect(timeBucketFor(t1)).toBe(timeBucketFor(t2));
+ });
+
+ it("returns different buckets for timestamps in different windows", () => {
+ const t1 = "2026-10-18T22:05:30.000Z";
+ const t2 = "2026-10-18T22:15:00.000Z";
+ expect(timeBucketFor(t1)).not.toBe(timeBucketFor(t2));
+ });
+
+ it("the bucket width equals TIME_BUCKET_SECONDS", () => {
+ const base = new Date("2026-10-18T22:00:00.000Z").getTime() / 1000;
+ const b1 = Math.floor(base / TIME_BUCKET_SECONDS);
+ const b2 = Math.floor((base + TIME_BUCKET_SECONDS - 1) / TIME_BUCKET_SECONDS);
+ const b3 = Math.floor((base + TIME_BUCKET_SECONDS) / TIME_BUCKET_SECONDS);
+ expect(b1).toBe(b2); // within the same bucket
+ expect(b1).not.toBe(b3); // across the boundary
+ });
+});
+
+describe("computeFingerprint", () => {
+ it("produces a 64-char hex digest", () => {
+ const fp = computeFingerprint(TX_A1, "testnet");
+ expect(fp.value).toMatch(/^[a-f0-9]{64}$/);
+ });
+
+ it("produces the same fingerprint for two structurally identical transactions", () => {
+ const fp1 = computeFingerprint(TX_A1, "testnet");
+ const fp2 = computeFingerprint(TX_A2, "testnet");
+ expect(fp1.value).toBe(fp2.value);
+ });
+
+ it("produces different fingerprints for structurally distinct transactions", () => {
+ const fp1 = computeFingerprint(TX_A1, "testnet");
+ const fp2 = computeFingerprint(TX_B1, "testnet");
+ expect(fp1.value).not.toBe(fp2.value);
+ });
+
+ it("is sensitive to the network field", () => {
+ const fpTestnet = computeFingerprint(TX_A1, "testnet");
+ const fpMainnet = computeFingerprint(TX_A1, "mainnet");
+ expect(fpTestnet.value).not.toBe(fpMainnet.value);
+ });
+
+ it("is sensitive to memo type", () => {
+ const noMemo = computeFingerprint(TX_A1, "testnet");
+ const withMemo = computeFingerprint({ ...TX_A1, memoType: "text", memo: "hello" }, "testnet");
+ expect(noMemo.value).not.toBe(withMemo.value);
+ });
+
+ it("op type order does not affect the fingerprint (order-insensitive)", () => {
+ const txAscending = { ...TX_A1, operationTypes: ["create_account", "payment"] };
+ const txDescending = { ...TX_A1, operationTypes: ["payment", "create_account"] };
+ const fp1 = computeFingerprint(txAscending, "testnet");
+ const fp2 = computeFingerprint(txDescending, "testnet");
+ expect(fp1.value).toBe(fp2.value);
+ });
+
+ it("stores the pre-image fields on the fingerprint object", () => {
+ const fp = computeFingerprint(TX_A1, "testnet");
+ expect(fp.sourceAccount).toBe(TX_A1.sourceAccount);
+ expect(fp.feeCharged).toBe(TX_A1.feeCharged);
+ expect(fp.network).toBe("testnet");
+ expect(fp.opTypesSorted).toEqual(["payment"]);
+ });
+});
+
+describe("groupByFingerprint", () => {
+ it("returns one group for the two transactions with identical fingerprints", () => {
+ const groups = groupByFingerprint([TX_A1, TX_A2, TX_B1], "testnet");
+ expect(groups).toHaveLength(1);
+ expect(groups[0].transactions).toHaveLength(2);
+ });
+
+ it("returns an empty array when no duplicates exist", () => {
+ const groups = groupByFingerprint([TX_A1, TX_B1], "testnet");
+ expect(groups).toHaveLength(0);
+ });
+
+ it("returns an empty array for a single transaction", () => {
+ expect(groupByFingerprint([TX_A1], "testnet")).toHaveLength(0);
+ });
+
+ it("returns an empty array for an empty list", () => {
+ expect(groupByFingerprint([], "testnet")).toHaveLength(0);
+ });
+
+ it("groups start with state pending", () => {
+ const groups = groupByFingerprint([TX_A1, TX_A2], "testnet");
+ expect(groups[0].state).toBe("pending");
+ });
+
+ it("group id equals the fingerprint value", () => {
+ const groups = groupByFingerprint([TX_A1, TX_A2], "testnet");
+ expect(groups[0].id).toBe(groups[0].fingerprint.value);
+ });
+
+ it("is deterministic — same input, same output", () => {
+ const g1 = groupByFingerprint([TX_A1, TX_A2], "testnet");
+ const g2 = groupByFingerprint([TX_A1, TX_A2], "testnet");
+ expect(g1[0].id).toBe(g2[0].id);
+ });
+});
diff --git a/features/duplicate-transaction-scanner/__tests__/format.test.ts b/features/duplicate-transaction-scanner/__tests__/format.test.ts
new file mode 100644
index 0000000..617b31e
--- /dev/null
+++ b/features/duplicate-transaction-scanner/__tests__/format.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import {
+ formatFee,
+ formatGroupState,
+ formatOpTypes,
+ formatTimeBucket,
+} from "@/features/duplicate-transaction-scanner/lib/format";
+
+describe("formatFee", () => {
+ it("renders a fee in stroops and XLM", () => {
+ const result = formatFee("100");
+ expect(result).toContain("100 stroops");
+ expect(result).toContain("XLM");
+ });
+
+ it("handles 10,000,000 stroops as exactly 1 XLM", () => {
+ const result = formatFee("10000000");
+ expect(result).toContain("10000000 stroops");
+ expect(result).toContain("1.0000000 XLM");
+ });
+
+ it("handles 0 fee gracefully", () => {
+ expect(() => formatFee("0")).not.toThrow();
+ });
+});
+
+describe("formatOpTypes", () => {
+ it("joins multiple types with comma", () => {
+ expect(formatOpTypes(["payment", "create_account"])).toBe("payment, create_account");
+ });
+
+ it("returns an em dash for an empty list", () => {
+ expect(formatOpTypes([])).toBe("—");
+ });
+
+ it("returns a single type without punctuation", () => {
+ expect(formatOpTypes(["payment"])).toBe("payment");
+ });
+});
+
+describe("formatGroupState", () => {
+ it("returns a human-readable label for every state", () => {
+ const states = ["pending", "confirmed", "dismissed", "deferred"] as const;
+ for (const state of states) {
+ const label = formatGroupState(state);
+ expect(typeof label).toBe("string");
+ expect(label.length).toBeGreaterThan(0);
+ }
+ });
+});
+
+describe("formatTimeBucket", () => {
+ it("returns a non-empty string", () => {
+ const result = formatTimeBucket(5_869_717);
+ expect(typeof result).toBe("string");
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it("includes the start time in ISO format", () => {
+ const result = formatTimeBucket(0);
+ expect(result).toContain("1970");
+ });
+});
diff --git a/features/duplicate-transaction-scanner/__tests__/review.test.ts b/features/duplicate-transaction-scanner/__tests__/review.test.ts
new file mode 100644
index 0000000..68daaed
--- /dev/null
+++ b/features/duplicate-transaction-scanner/__tests__/review.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import {
+ applyReview,
+ isTerminalState,
+ mergeIntoStore,
+} from "@/features/duplicate-transaction-scanner/lib/review";
+import {
+ PENDING_GROUP,
+} from "@/features/duplicate-transaction-scanner/fixtures/scanner.fixture";
+import type {
+ CandidateGroup,
+ ReviewInput,
+} from "@/features/duplicate-transaction-scanner/types";
+
+const ACTOR_ID = "session:test-actor";
+
+function reviewInput(
+ groupId: string,
+ action: ReviewInput["action"],
+ reason?: string,
+): ReviewInput {
+ return { groupId, action, actorId: ACTOR_ID, reason };
+}
+
+describe("isTerminalState", () => {
+ it("returns true for confirmed and dismissed", () => {
+ expect(isTerminalState("confirmed")).toBe(true);
+ expect(isTerminalState("dismissed")).toBe(true);
+ });
+
+ it("returns false for pending and deferred", () => {
+ expect(isTerminalState("pending")).toBe(false);
+ expect(isTerminalState("deferred")).toBe(false);
+ });
+});
+
+describe("applyReview", () => {
+ it("transitions pending → confirmed on confirm", () => {
+ const result = applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "confirm"));
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.state).toBe("confirmed");
+ });
+
+ it("transitions pending → dismissed on dismiss", () => {
+ const result = applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "dismiss"));
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.state).toBe("dismissed");
+ });
+
+ it("transitions pending → deferred on defer", () => {
+ const result = applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "defer"));
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.state).toBe("deferred");
+ });
+
+ it("records reviewedAt and reviewedBy on success", () => {
+ const result = applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "confirm"));
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.reviewedAt).toBeTruthy();
+ expect(result.value.reviewedBy).toBe(ACTOR_ID);
+ });
+
+ it("stores the optional reason on the group", () => {
+ const result = applyReview(
+ PENDING_GROUP,
+ reviewInput(PENDING_GROUP.id, "dismiss", "false_positive"),
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.reviewReason).toBe("false_positive");
+ });
+
+ it("refuses to transition a confirmed group", () => {
+ const confirmed: CandidateGroup = { ...PENDING_GROUP, state: "confirmed" };
+ const result = applyReview(confirmed, reviewInput(PENDING_GROUP.id, "confirm"));
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.code).toBe("invalid_transition");
+ });
+
+ it("refuses to transition a dismissed group", () => {
+ const dismissed: CandidateGroup = { ...PENDING_GROUP, state: "dismissed" };
+ const result = applyReview(dismissed, reviewInput(PENDING_GROUP.id, "dismiss"));
+ expect(result.ok).toBe(false);
+ });
+
+ it("allows confirming a deferred group", () => {
+ const deferred: CandidateGroup = { ...PENDING_GROUP, state: "deferred" };
+ const result = applyReview(deferred, reviewInput(PENDING_GROUP.id, "confirm"));
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.state).toBe("confirmed");
+ });
+
+ it("allows dismissing a deferred group", () => {
+ const deferred: CandidateGroup = { ...PENDING_GROUP, state: "deferred" };
+ const result = applyReview(deferred, reviewInput(PENDING_GROUP.id, "dismiss"));
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.state).toBe("dismissed");
+ });
+
+ it("does not mutate the original group", () => {
+ const original = { ...PENDING_GROUP };
+ applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "confirm"));
+ expect(PENDING_GROUP.state).toBe(original.state);
+ });
+});
+
+describe("mergeIntoStore", () => {
+ it("adds new incoming groups to an empty store", () => {
+ const result = mergeIntoStore([PENDING_GROUP], []);
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(PENDING_GROUP.id);
+ });
+
+ it("preserves a confirmed decision from the store", () => {
+ const confirmed: CandidateGroup = { ...PENDING_GROUP, state: "confirmed" };
+ const result = mergeIntoStore([PENDING_GROUP], [confirmed]);
+ expect(result[0].state).toBe("confirmed");
+ });
+
+ it("preserves a dismissed decision from the store", () => {
+ const dismissed: CandidateGroup = { ...PENDING_GROUP, state: "dismissed" };
+ const result = mergeIntoStore([PENDING_GROUP], [dismissed]);
+ expect(result[0].state).toBe("dismissed");
+ });
+
+ it("preserves a deferred decision from the store", () => {
+ const deferred: CandidateGroup = { ...PENDING_GROUP, state: "deferred" };
+ const result = mergeIntoStore([PENDING_GROUP], [deferred]);
+ expect(result[0].state).toBe("deferred");
+ });
+
+ it("retains historical groups not present in the incoming scan", () => {
+ const historical: CandidateGroup = {
+ ...PENDING_GROUP,
+ id: "historical-group",
+ state: "confirmed",
+ };
+ const result = mergeIntoStore([], [historical]);
+ expect(result.some((g) => g.id === "historical-group")).toBe(true);
+ });
+
+ it("sorts pending groups before deferred groups before resolved groups", () => {
+ const deferred: CandidateGroup = {
+ ...PENDING_GROUP,
+ id: "deferred-1",
+ state: "deferred",
+ };
+ const confirmed: CandidateGroup = {
+ ...PENDING_GROUP,
+ id: "confirmed-1",
+ state: "confirmed",
+ };
+ const result = mergeIntoStore([PENDING_GROUP, deferred, confirmed], []);
+ expect(result[0].state).toBe("pending");
+ expect(result[1].state).toBe("deferred");
+ expect(result[2].state).toBe("confirmed");
+ });
+});
diff --git a/features/duplicate-transaction-scanner/__tests__/scanner.test.ts b/features/duplicate-transaction-scanner/__tests__/scanner.test.ts
new file mode 100644
index 0000000..d762885
--- /dev/null
+++ b/features/duplicate-transaction-scanner/__tests__/scanner.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "vitest";
+import { withMswHandlers } from "@/core/testing/msw";
+import { scanForDuplicates } from "@/features/duplicate-transaction-scanner/lib/scanner";
+import {
+ handlers,
+ notFoundHandler,
+ rateLimitedHandler,
+} from "@/features/duplicate-transaction-scanner/msw/handlers";
+import {
+ HASH_A1,
+ HASH_A2,
+ HASH_B1,
+} from "@/features/duplicate-transaction-scanner/fixtures/scanner.fixture";
+
+const server = withMswHandlers(...handlers);
+
+const TESTNET_BASE = "https://horizon-testnet.stellar.org";
+
+describe("scanForDuplicates", () => {
+ it("returns one duplicate group for two identical transactions", async () => {
+ const result = await scanForDuplicates(
+ { hashes: [HASH_A1, HASH_A2], network: "testnet" },
+ TESTNET_BASE,
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.scannedCount).toBe(2);
+ expect(result.value.groupCount).toBe(1);
+ expect(result.value.groups).toHaveLength(1);
+ expect(result.value.groups[0].transactions).toHaveLength(2);
+ expect(result.value.groups[0].state).toBe("pending");
+ });
+
+ it("returns no groups when there are no duplicates", async () => {
+ const result = await scanForDuplicates(
+ { hashes: [HASH_A1, HASH_B1], network: "testnet" },
+ TESTNET_BASE,
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.groupCount).toBe(0);
+ expect(result.value.scannedCount).toBe(2);
+ });
+
+ it("returns not_found when a hash is missing from Horizon", async () => {
+ server.use(notFoundHandler);
+ const result = await scanForDuplicates(
+ { hashes: [HASH_A1], network: "testnet" },
+ TESTNET_BASE,
+ );
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.code).toBe("not_found");
+ });
+
+ it("returns rate_limited on HTTP 429", async () => {
+ server.use(rateLimitedHandler);
+ const result = await scanForDuplicates(
+ { hashes: [HASH_A1], network: "testnet" },
+ TESTNET_BASE,
+ );
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.code).toBe("rate_limited");
+ });
+
+ it("includes the network and scannedAt in the result", async () => {
+ const result = await scanForDuplicates(
+ { hashes: [HASH_A1, HASH_A2], network: "testnet" },
+ TESTNET_BASE,
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.network).toBe("testnet");
+ expect(result.value.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ });
+
+ it("duplicate groups have deterministic ids (same input → same group id)", async () => {
+ const r1 = await scanForDuplicates(
+ { hashes: [HASH_A1, HASH_A2], network: "testnet" },
+ TESTNET_BASE,
+ );
+ const r2 = await scanForDuplicates(
+ { hashes: [HASH_A1, HASH_A2], network: "testnet" },
+ TESTNET_BASE,
+ );
+ expect(r1.ok && r2.ok).toBe(true);
+ if (!r1.ok || !r2.ok) return;
+ expect(r1.value.groups[0].id).toBe(r2.value.groups[0].id);
+ });
+});
diff --git a/features/duplicate-transaction-scanner/__tests__/schema.test.ts b/features/duplicate-transaction-scanner/__tests__/schema.test.ts
new file mode 100644
index 0000000..c7d9fa2
--- /dev/null
+++ b/features/duplicate-transaction-scanner/__tests__/schema.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import { MAX_HASHES, parseDuplicateScanInput } from "@/features/duplicate-transaction-scanner/schema";
+import { HASH_A1, HASH_A2 } from "@/features/duplicate-transaction-scanner/fixtures/scanner.fixture";
+
+describe("parseDuplicateScanInput", () => {
+ it("rejects empty input", () => {
+ expect(parseDuplicateScanInput("", "testnet")).toEqual({ ok: false, code: "empty_input" });
+ expect(parseDuplicateScanInput(" \n\n ", "testnet")).toEqual({ ok: false, code: "empty_input" });
+ });
+
+ it("accepts a single valid hash", () => {
+ const result = parseDuplicateScanInput(HASH_A1, "testnet");
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.hashes).toEqual([HASH_A1]);
+ expect(result.value.network).toBe("testnet");
+ });
+
+ it("accepts multiple hashes separated by newlines", () => {
+ const result = parseDuplicateScanInput(`${HASH_A1}\n${HASH_A2}`, "testnet");
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.hashes).toHaveLength(2);
+ });
+
+ it("normalises hashes to lower case", () => {
+ const upper = HASH_A1.toUpperCase();
+ const result = parseDuplicateScanInput(upper, "testnet");
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.hashes[0]).toBe(HASH_A1);
+ });
+
+ it("deduplicates hashes submitted twice", () => {
+ const result = parseDuplicateScanInput(`${HASH_A1}\n${HASH_A1}`, "testnet");
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.hashes).toHaveLength(1);
+ });
+
+ it("rejects a non-hex string", () => {
+ const bad = "z".repeat(64);
+ expect(parseDuplicateScanInput(bad, "testnet")).toEqual({ ok: false, code: "invalid_hash" });
+ });
+
+ it("rejects a hash that is too short", () => {
+ const short = "a".repeat(63);
+ expect(parseDuplicateScanInput(short, "testnet")).toEqual({ ok: false, code: "invalid_hash" });
+ });
+
+ it("rejects a hash that is too long", () => {
+ const long = "a".repeat(65);
+ expect(parseDuplicateScanInput(long, "testnet")).toEqual({ ok: false, code: "invalid_hash" });
+ });
+
+ it(`rejects more than ${MAX_HASHES} hashes`, () => {
+ const hashes = Array.from({ length: MAX_HASHES + 1 }, (_, i) =>
+ String(i).padStart(2, "0").padEnd(64, "0"),
+ ).join("\n");
+ expect(parseDuplicateScanInput(hashes, "testnet")).toEqual({
+ ok: false,
+ code: "too_many_hashes",
+ });
+ });
+
+ it(`accepts exactly ${MAX_HASHES} hashes`, () => {
+ const hashes = Array.from({ length: MAX_HASHES }, (_, i) =>
+ String(i).padStart(2, "0").padEnd(64, "0"),
+ ).join("\n");
+ const result = parseDuplicateScanInput(hashes, "testnet");
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.hashes).toHaveLength(MAX_HASHES);
+ });
+
+ it("strips surrounding whitespace from each line", () => {
+ const result = parseDuplicateScanInput(` ${HASH_A1} \n ${HASH_A2} `, "testnet");
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.hashes).toEqual([HASH_A1, HASH_A2]);
+ });
+});
diff --git a/features/duplicate-transaction-scanner/components/DuplicateScannerEmptyState.tsx b/features/duplicate-transaction-scanner/components/DuplicateScannerEmptyState.tsx
new file mode 100644
index 0000000..e47e4ac
--- /dev/null
+++ b/features/duplicate-transaction-scanner/components/DuplicateScannerEmptyState.tsx
@@ -0,0 +1,13 @@
+import { GitCompare } from "lucide-react";
+import { EmptyState } from "@/core/ui";
+import { copy } from "@/features/duplicate-transaction-scanner/copy";
+
+export function DuplicateScannerEmptyState() {
+ return (
+
+ );
+}
diff --git a/features/duplicate-transaction-scanner/components/DuplicateScannerForm.tsx b/features/duplicate-transaction-scanner/components/DuplicateScannerForm.tsx
new file mode 100644
index 0000000..c3eca21
--- /dev/null
+++ b/features/duplicate-transaction-scanner/components/DuplicateScannerForm.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { useState } from "react";
+import { Button, Field, Textarea } from "@/core/ui";
+import { copy } from "@/features/duplicate-transaction-scanner/copy";
+
+interface DuplicateScannerFormProps {
+ onScan: (rawHashes: string) => void;
+ isLoading: boolean;
+}
+
+export function DuplicateScannerForm({ onScan, isLoading }: DuplicateScannerFormProps) {
+ const [raw, setRaw] = useState("");
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ onScan(raw);
+ }
+
+ return (
+
+ );
+}
diff --git a/features/duplicate-transaction-scanner/components/DuplicateScannerPanel.tsx b/features/duplicate-transaction-scanner/components/DuplicateScannerPanel.tsx
new file mode 100644
index 0000000..37f74fa
--- /dev/null
+++ b/features/duplicate-transaction-scanner/components/DuplicateScannerPanel.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useNetwork } from "@/core/network/NetworkProvider";
+import { SkeletonRows, StatusMessage } from "@/core/ui";
+import { copy } from "@/features/duplicate-transaction-scanner/copy";
+import { DuplicateScannerEmptyState } from "@/features/duplicate-transaction-scanner/components/DuplicateScannerEmptyState";
+import { DuplicateScannerForm } from "@/features/duplicate-transaction-scanner/components/DuplicateScannerForm";
+import { DuplicateScannerResult } from "@/features/duplicate-transaction-scanner/components/DuplicateScannerResult";
+import { useDuplicateScanner } from "@/features/duplicate-transaction-scanner/hooks/useDuplicateScanner";
+import type { ReviewInput } from "@/features/duplicate-transaction-scanner/types";
+
+/**
+ * A stable "actor id" derived from the session. In a real deployment this
+ * would come from auth context; here we use a per-session random string
+ * persisted in sessionStorage so the audit trail is always attributed.
+ */
+function getSessionActorId(): string {
+ if (typeof window === "undefined") return "anonymous";
+ const key = "utix:dup-scan:actor";
+ const existing = sessionStorage.getItem(key);
+ if (existing) return existing;
+ const id = `session:${crypto.randomUUID()}`;
+ sessionStorage.setItem(key, id);
+ return id;
+}
+
+export function DuplicateScannerPanel() {
+ const { network } = useNetwork();
+ const scanner = useDuplicateScanner();
+
+ function handleScan(rawHashes: string) {
+ void scanner.scan(rawHashes, network);
+ }
+
+ function handleReview(input: Omit) {
+ scanner.review({ ...input, actorId: getSessionActorId() });
+ }
+
+ return (
+
+ {/* Input form is always visible so users can re-scan */}
+
+
+ {/* Result area */}
+ {scanner.phase === "idle" && (
+
+ )}
+
+ {scanner.phase === "loading" && (
+
+ )}
+
+ {scanner.phase === "error" && scanner.errorCode && (
+
+ )}
+
+ {scanner.phase === "success" && scanner.result && (
+
+ )}
+
+ );
+}
diff --git a/features/duplicate-transaction-scanner/components/DuplicateScannerResult.tsx b/features/duplicate-transaction-scanner/components/DuplicateScannerResult.tsx
new file mode 100644
index 0000000..af0197a
--- /dev/null
+++ b/features/duplicate-transaction-scanner/components/DuplicateScannerResult.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { Badge, Button, Card, DataList } from "@/core/ui";
+import { copy } from "@/features/duplicate-transaction-scanner/copy";
+import {
+ formatCreatedAt,
+ formatFee,
+ formatGroupState,
+ formatOpTypes,
+ formatTimeBucket,
+} from "@/features/duplicate-transaction-scanner/lib/format";
+import { isTerminalState } from "@/features/duplicate-transaction-scanner/lib/review";
+import type {
+ CandidateGroup,
+ CandidateGroupState,
+ DuplicateScanResult,
+ ReviewInput,
+} from "@/features/duplicate-transaction-scanner/types";
+import type { DataListItem } from "@/core/ui/DataList";
+
+interface DuplicateScannerResultProps {
+ result: Omit;
+ groups: CandidateGroup[];
+ onReview: (input: Omit) => void;
+}
+
+function stateToBadgeTone(
+ state: CandidateGroupState,
+): "warning" | "danger" | "success" | "muted" | "info" {
+ switch (state) {
+ case "pending": return "warning";
+ case "deferred": return "info";
+ case "confirmed": return "danger";
+ case "dismissed": return "muted";
+ }
+}
+
+function CandidateGroupCard({
+ group,
+ onReview,
+}: {
+ group: CandidateGroup;
+ onReview: DuplicateScannerResultProps["onReview"];
+}) {
+ const { fingerprint } = group;
+ const terminal = isTerminalState(group.state);
+
+ const fingerprintItems: DataListItem[] = [
+ {
+ label: copy.groupFingerprintLabel,
+ value: (
+ {fingerprint.value}
+ ),
+ },
+ { label: copy.groupSourceLabel, value: fingerprint.sourceAccount },
+ { label: copy.groupOpsLabel, value: formatOpTypes(fingerprint.opTypesSorted) },
+ { label: copy.groupFeeLabel, value: formatFee(fingerprint.feeCharged) },
+ {
+ label: copy.groupMemoLabel,
+ value: fingerprint.memoType === "none"
+ ? "—"
+ : `${fingerprint.memoType}: ${fingerprint.memo || "—"}`,
+ },
+ { label: copy.groupWindowLabel, value: formatTimeBucket(fingerprint.timeBucket) },
+ { label: copy.groupStateLabel, value: formatGroupState(group.state) },
+ ];
+
+ return (
+
+ {/* Header row: state badge + fingerprint short */}
+
+
+ {group.transactions.length} matching transactions
+
+
+ {copy.states[group.state]}
+
+
+
+ {/* Fingerprint details */}
+
+
+ {/* Transaction list */}
+
+
+ {copy.groupTransactionsLabel}
+
+
+ {group.transactions.map((tx) => (
+
+
+ {tx.hash}
+
+ {copy.transactionLedgerLabel} {tx.ledger}
+
+ {formatCreatedAt(tx.createdAt)}
+
+ {tx.successful ? copy.transactionSuccessful : copy.transactionFailed}
+
+
+
+ ))}
+
+
+
+ {/* Review actions — hidden for terminal groups */}
+ {!terminal && (
+
+
+
+
+
+ )}
+
+ );
+}
+
+export function DuplicateScannerResult({
+ result,
+ groups,
+ onReview,
+}: DuplicateScannerResultProps) {
+ const summary =
+ groups.filter((g) => g.state === "pending" || g.state === "deferred").length === 0
+ ? copy.resultSummaryNone(result.scannedCount)
+ : copy.resultSummaryFound(result.scannedCount, result.groupCount);
+
+ return (
+
+ {/* Summary */}
+ {summary}
+
+ {/* Group list */}
+ {groups.length > 0 && (
+
+ {groups.map((group) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/features/duplicate-transaction-scanner/copy.ts b/features/duplicate-transaction-scanner/copy.ts
new file mode 100644
index 0000000..5a73c56
--- /dev/null
+++ b/features/duplicate-transaction-scanner/copy.ts
@@ -0,0 +1,81 @@
+/**
+ * All user-facing strings for the duplicate-transaction-scanner slice.
+ *
+ * No text lives in components — they import this object and assert against it
+ * in tests.
+ */
+export const copy = {
+ // Panel heading & description
+ title: "Duplicate Transaction Scanner",
+ description:
+ "Paste up to 20 transaction hashes (one per line). The scanner computes a deterministic fingerprint for each and groups any that appear structurally identical. Review each candidate group and confirm, dismiss, or defer.",
+
+ // Form
+ hashesLabel: "Transaction Hashes",
+ hashesHint: "One 64-character hex hash per line. Maximum 20.",
+ hashesPh: "e.g. 6bc1bee22e40...",
+ submitLabel: "Scan for Duplicates",
+
+ // Empty state
+ emptyHeading: "No scan yet",
+ emptyBody:
+ "Enter one or more Stellar transaction hashes above and click "Scan for Duplicates".",
+
+ // Loading
+ loadingLabel: "Scanning transactions…",
+
+ // Success summary
+ resultSummaryNone: (n: number) =>
+ `Scanned ${n} transaction${n === 1 ? "" : "s"} — no duplicate groups found.`,
+ resultSummaryFound: (scanned: number, groups: number) =>
+ `Scanned ${scanned} transaction${scanned === 1 ? "" : "s"} — ${groups} duplicate group${groups === 1 ? "" : "s"} found.`,
+
+ // Group card
+ groupFingerprintLabel: "Fingerprint",
+ groupStateLabel: "State",
+ groupSourceLabel: "Source account",
+ groupOpsLabel: "Operation types",
+ groupFeeLabel: "Fee charged",
+ groupMemoLabel: "Memo",
+ groupWindowLabel: "5-min time window",
+ groupTransactionsLabel: "Matching transactions",
+ transactionHashLabel: "Hash",
+ transactionLedgerLabel: "Ledger",
+ transactionCreatedAtLabel: "Created at",
+ transactionSuccessful: "Successful",
+ transactionFailed: "Failed",
+
+ // Review actions
+ confirmLabel: "Confirm Duplicate",
+ confirmAria: "Confirm this group as real duplicates",
+ dismissLabel: "Dismiss",
+ dismissAria: "Dismiss this group — mark transactions as distinct",
+ deferLabel: "Defer",
+ deferAria: "Defer review of this group to the next scan",
+
+ // State labels
+ states: {
+ pending: "Pending review",
+ confirmed: "Confirmed duplicate",
+ dismissed: "Dismissed",
+ deferred: "Deferred",
+ } satisfies Record,
+
+ // Error messages
+ errors: {
+ empty_input: "Enter at least one transaction hash.",
+ too_many_hashes: "Maximum 20 hashes per scan. Remove some and try again.",
+ invalid_hash:
+ "Every hash must be a 64-character hexadecimal string. Check for typos or extra whitespace.",
+ not_found:
+ "One or more hashes were not found on the selected network. Check the hash and network.",
+ rate_limited: "Horizon rate-limited this request. Wait a moment and try again.",
+ request_failed: "Horizon request failed. Check your network connection and try again.",
+ invalid_transition:
+ "This group cannot be reviewed — it has already been confirmed or dismissed.",
+ } satisfies Record,
+
+ // Accessibility
+ scanResultRegionLabel: "Scan results",
+ groupListLabel: "Candidate duplicate groups",
+} as const;
diff --git a/features/duplicate-transaction-scanner/fixtures/scanner.fixture.ts b/features/duplicate-transaction-scanner/fixtures/scanner.fixture.ts
new file mode 100644
index 0000000..6546df6
--- /dev/null
+++ b/features/duplicate-transaction-scanner/fixtures/scanner.fixture.ts
@@ -0,0 +1,130 @@
+/**
+ * Deterministic fixtures for the duplicate-transaction-scanner tests.
+ *
+ * All hashes are derived from fixed byte seeds so they are stable across SDK
+ * upgrades that might rename exports. We do not hand-type hex strings — we
+ * compute them from known preimages.
+ */
+import type {
+ CandidateGroup,
+ FetchedTransaction,
+} from "@/features/duplicate-transaction-scanner/types";
+
+// ---------------------------------------------------------------------------
+// Fixed test hashes (64 hex chars, lowercase)
+// ---------------------------------------------------------------------------
+
+/** Hash A1 — first transaction in the duplicate pair. */
+export const HASH_A1 =
+ "aaaa000000000000000000000000000000000000000000000000000000000001";
+/** Hash A2 — duplicate of A1 (same fingerprint). */
+export const HASH_A2 =
+ "aaaa000000000000000000000000000000000000000000000000000000000002";
+/** Hash B1 — unique transaction (no match). */
+export const HASH_B1 =
+ "bbbb000000000000000000000000000000000000000000000000000000000001";
+
+// ---------------------------------------------------------------------------
+// Source accounts
+// ---------------------------------------------------------------------------
+
+/** A valid-looking Stellar public key used in fixtures. */
+export const ACCOUNT_A = "GDZAPPY3FOHSXMW3UG5LLJI4HCPBMXKMOCQNFLQ3FKXI5DXKBG5CNXF";
+export const ACCOUNT_B = "GDKIJJIKXLOM2NRMPNQZUUYK24ZPVFC6426GZAICZ7KXH3TUVHGJCLTY";
+
+// ---------------------------------------------------------------------------
+// Timestamps — both in the same 5-minute bucket
+// ---------------------------------------------------------------------------
+
+/** 2026-10-18 22:05:00 UTC → bucket = floor(1760915100 / 300) = 5869717 */
+export const TS_A1 = "2026-10-18T22:05:30.000Z";
+/** 2026-10-18 22:06:00 UTC — same bucket as TS_A1 */
+export const TS_A2 = "2026-10-18T22:06:00.000Z";
+/** 2026-10-18 22:15:00 UTC — different bucket to A1/A2 */
+export const TS_B1 = "2026-10-18T22:15:00.000Z";
+
+// ---------------------------------------------------------------------------
+// FetchedTransaction fixtures
+// ---------------------------------------------------------------------------
+
+export const TX_A1: FetchedTransaction = {
+ hash: HASH_A1,
+ sourceAccount: ACCOUNT_A,
+ feeCharged: "100",
+ memoType: "none",
+ memo: "",
+ createdAt: TS_A1,
+ operationTypes: ["payment"],
+ ledger: 54_000_001,
+ successful: true,
+};
+
+export const TX_A2: FetchedTransaction = {
+ hash: HASH_A2,
+ sourceAccount: ACCOUNT_A,
+ feeCharged: "100",
+ memoType: "none",
+ memo: "",
+ createdAt: TS_A2,
+ operationTypes: ["payment"],
+ ledger: 54_000_002,
+ successful: true,
+};
+
+export const TX_B1: FetchedTransaction = {
+ hash: HASH_B1,
+ sourceAccount: ACCOUNT_B,
+ feeCharged: "200",
+ memoType: "text",
+ memo: "hello",
+ createdAt: TS_B1,
+ operationTypes: ["create_account"],
+ ledger: 54_001_000,
+ successful: true,
+};
+
+// ---------------------------------------------------------------------------
+// Horizon API response stubs
+// ---------------------------------------------------------------------------
+
+export function makeHorizonTransaction(tx: FetchedTransaction) {
+ return {
+ hash: tx.hash,
+ source_account: tx.sourceAccount,
+ fee_charged: tx.feeCharged,
+ memo_type: tx.memoType,
+ ...(tx.memo ? { memo: tx.memo } : {}),
+ created_at: tx.createdAt,
+ ledger: tx.ledger,
+ successful: tx.successful,
+ };
+}
+
+export function makeHorizonOperationsPage(types: string[]) {
+ return {
+ _embedded: {
+ records: types.map((type) => ({ type })),
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Pre-built candidate group fixture
+// ---------------------------------------------------------------------------
+
+/** A pending group built from TX_A1 + TX_A2, for review workflow tests. */
+export const PENDING_GROUP: CandidateGroup = {
+ id: "pending-group-fixture-id",
+ fingerprint: {
+ value: "pending-group-fixture-id",
+ sourceAccount: ACCOUNT_A,
+ opTypesSorted: ["payment"],
+ feeCharged: "100",
+ memoType: "none",
+ memo: "",
+ network: "testnet",
+ timeBucket: 5_869_717,
+ },
+ transactions: [TX_A1, TX_A2],
+ state: "pending",
+};
diff --git a/features/duplicate-transaction-scanner/hooks/useDuplicateScanner.ts b/features/duplicate-transaction-scanner/hooks/useDuplicateScanner.ts
new file mode 100644
index 0000000..8da9c8c
--- /dev/null
+++ b/features/duplicate-transaction-scanner/hooks/useDuplicateScanner.ts
@@ -0,0 +1,126 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { parseDuplicateScanInput } from "@/features/duplicate-transaction-scanner/schema";
+import { scanForDuplicates } from "@/features/duplicate-transaction-scanner/lib/scanner";
+import {
+ applyReview,
+ loadGroups,
+ mergeIntoStore,
+ saveGroups,
+} from "@/features/duplicate-transaction-scanner/lib/review";
+import type {
+ CandidateGroup,
+ DuplicateScanErrorCode,
+ DuplicateScanResult,
+ ReviewInput,
+} from "@/features/duplicate-transaction-scanner/types";
+import type { Result } from "@/core/result/result";
+
+export type DuplicateScannerPhase = "idle" | "loading" | "success" | "error";
+
+export interface DuplicateScannerState {
+ phase: DuplicateScannerPhase;
+ /** The most recent scan result (metadata only — groups live in `groups`). */
+ result: Omit | null;
+ /**
+ * The full merged group list: includes persisted review decisions from
+ * previous scans as well as the results of the latest scan.
+ */
+ groups: CandidateGroup[];
+ errorCode: DuplicateScanErrorCode | null;
+}
+
+const INITIAL_STATE: DuplicateScannerState = {
+ phase: "idle",
+ result: null,
+ groups: [],
+ errorCode: null,
+};
+
+export interface DuplicateScannerActions {
+ scan: (rawHashes: string, network: string) => Promise;
+ review: (input: ReviewInput) => Result;
+ reset: () => void;
+}
+
+export function useDuplicateScanner(): DuplicateScannerState & DuplicateScannerActions {
+ const [state, setState] = useState(INITIAL_STATE);
+
+ // Hydrate persisted groups on mount.
+ useEffect(() => {
+ const persisted = loadGroups();
+ if (persisted.length > 0) {
+ setState((s) => ({ ...s, groups: persisted }));
+ }
+ }, []);
+
+ const scan = useCallback(async (rawHashes: string, network: string) => {
+ const parseResult = parseDuplicateScanInput(rawHashes, network);
+ if (!parseResult.ok) {
+ setState((s) => ({ ...s, phase: "error", errorCode: parseResult.code }));
+ return;
+ }
+
+ setState((s) => ({ ...s, phase: "loading", errorCode: null }));
+
+ const scanResult = await scanForDuplicates(parseResult.value);
+ if (!scanResult.ok) {
+ setState((s) => ({ ...s, phase: "error", errorCode: scanResult.code }));
+ return;
+ }
+
+ const { groups: freshGroups, ...meta } = scanResult.value;
+
+ // Merge fresh results with the persisted store so existing review
+ // decisions (confirmed, dismissed, deferred) are preserved.
+ const currentStored = loadGroups();
+ const merged = mergeIntoStore(freshGroups, currentStored);
+ saveGroups(merged);
+
+ setState({
+ phase: "success",
+ result: meta,
+ groups: merged,
+ errorCode: null,
+ });
+ }, []);
+
+ const review = useCallback(
+ (input: ReviewInput): Result => {
+ let outcome: Result = {
+ ok: false,
+ code: "invalid_transition",
+ };
+
+ setState((s) => {
+ const idx = s.groups.findIndex((g) => g.id === input.groupId);
+ if (idx === -1) return s;
+
+ const group = s.groups[idx];
+ const reviewResult = applyReview(group, input);
+ if (!reviewResult.ok) {
+ outcome = reviewResult;
+ return s;
+ }
+
+ outcome = { ok: true, value: undefined };
+ const updated = [...s.groups];
+ updated[idx] = reviewResult.value;
+
+ // Persist after state update.
+ saveGroups(updated);
+ return { ...s, groups: updated };
+ });
+
+ return outcome;
+ },
+ [],
+ );
+
+ const reset = useCallback(() => {
+ setState(INITIAL_STATE);
+ }, []);
+
+ return { ...state, scan, review, reset };
+}
diff --git a/features/duplicate-transaction-scanner/lib/fingerprint.ts b/features/duplicate-transaction-scanner/lib/fingerprint.ts
new file mode 100644
index 0000000..817f622
--- /dev/null
+++ b/features/duplicate-transaction-scanner/lib/fingerprint.ts
@@ -0,0 +1,125 @@
+/**
+ * Deterministic transaction fingerprinting and duplicate grouping.
+ *
+ * Two transactions are considered structurally duplicate when they share the
+ * same source account, the same multiset of operation types, the same fee, the
+ * same memo, the same network, *and* fall within the same 5-minute time window.
+ *
+ * The fingerprint is a SHA-256 digest of a minimal canonical JSON object that
+ * encodes all of those fields. Using a hash keeps group ids short and stable —
+ * the pre-image fields are stored alongside the digest so a reviewer can see
+ * exactly why two transactions were grouped.
+ *
+ * Time bucketing intentionally coarsens the comparison: identical operations
+ * submitted in the same 5-minute window are the most common accidental
+ * duplicates (e.g. a double-submit from a UI retry). Transactions that are
+ * hours apart but otherwise identical are NOT flagged — they are almost
+ * certainly intentional repetitions.
+ */
+
+import { sha256Hex } from "@/core/ledger/sha256";
+import type {
+ CandidateGroup,
+ DuplicateFingerprint,
+ FetchedTransaction,
+} from "@/features/duplicate-transaction-scanner/types";
+
+/** Width of each time bucket in seconds (5 minutes). */
+export const TIME_BUCKET_SECONDS = 300;
+
+/**
+ * Compute the time bucket index for an ISO-8601 timestamp.
+ * Two times map to the same bucket iff they are within the same 5-minute
+ * window aligned to the Unix epoch.
+ */
+export function timeBucketFor(isoTimestamp: string): number {
+ const epochSeconds = Math.floor(new Date(isoTimestamp).getTime() / 1000);
+ return Math.floor(epochSeconds / TIME_BUCKET_SECONDS);
+}
+
+/**
+ * Build a canonical JSON string that uniquely identifies the "shape" of a
+ * transaction within its time bucket. Key ordering is fixed; field names are
+ * intentionally short to minimise the pre-image length.
+ */
+function canonicalPreImage(
+ tx: FetchedTransaction,
+ network: string,
+): string {
+ const bucket = timeBucketFor(tx.createdAt);
+ const obj = {
+ src: tx.sourceAccount,
+ ops: tx.operationTypes.slice().sort(),
+ fee: tx.feeCharged,
+ mt: tx.memoType,
+ m: tx.memo,
+ net: network,
+ tb: bucket,
+ };
+ // JSON.stringify with sorted keys produces a deterministic string because the
+ // key order is defined — the object literal above has a fixed key order.
+ return JSON.stringify(obj);
+}
+
+/** Compute the fingerprint for one transaction. */
+export function computeFingerprint(
+ tx: FetchedTransaction,
+ network: string,
+): DuplicateFingerprint {
+ const preImage = canonicalPreImage(tx, network);
+ return {
+ value: sha256Hex(preImage),
+ sourceAccount: tx.sourceAccount,
+ opTypesSorted: tx.operationTypes.slice().sort(),
+ feeCharged: tx.feeCharged,
+ memoType: tx.memoType,
+ memo: tx.memo,
+ network,
+ timeBucket: timeBucketFor(tx.createdAt),
+ };
+}
+
+/**
+ * Group a list of fetched transactions into candidate duplicate groups.
+ *
+ * Only groups with 2 or more transactions are included — a group of 1 is not
+ * a duplicate by definition.
+ *
+ * Groups are sorted by descending transaction count (most duplicates first)
+ * so the most actionable groups appear at the top of the review queue.
+ */
+export function groupByFingerprint(
+ txs: FetchedTransaction[],
+ network: string,
+): CandidateGroup[] {
+ const byFingerprint = new Map<
+ string,
+ { fingerprint: DuplicateFingerprint; transactions: FetchedTransaction[] }
+ >();
+
+ for (const tx of txs) {
+ const fp = computeFingerprint(tx, network);
+ const existing = byFingerprint.get(fp.value);
+ if (existing) {
+ existing.transactions.push(tx);
+ } else {
+ byFingerprint.set(fp.value, { fingerprint: fp, transactions: [tx] });
+ }
+ }
+
+ const groups: CandidateGroup[] = [];
+ for (const [id, { fingerprint, transactions }] of byFingerprint) {
+ if (transactions.length >= 2) {
+ groups.push({
+ id,
+ fingerprint,
+ transactions,
+ state: "pending",
+ });
+ }
+ }
+
+ // Most duplicates first.
+ groups.sort((a, b) => b.transactions.length - a.transactions.length);
+ return groups;
+}
diff --git a/features/duplicate-transaction-scanner/lib/format.ts b/features/duplicate-transaction-scanner/lib/format.ts
new file mode 100644
index 0000000..17a9054
--- /dev/null
+++ b/features/duplicate-transaction-scanner/lib/format.ts
@@ -0,0 +1,59 @@
+import { formatDateTime } from "@/core/format/date";
+import type { CandidateGroupState } from "@/features/duplicate-transaction-scanner/types";
+
+/** 5 minutes in seconds — must match TIME_BUCKET_SECONDS. */
+const BUCKET_SECONDS = 300;
+
+/** Stroops per XLM. */
+const STROOPS_PER_XLM = 10_000_000n;
+
+/**
+ * Convert a time bucket index back to a human-readable window string.
+ * e.g. bucket 5 571 048 → "2026-10-18T22:05:00Z – 22:10:00Z"
+ */
+export function formatTimeBucket(bucket: number): string {
+ const startSec = bucket * BUCKET_SECONDS;
+ const endSec = startSec + BUCKET_SECONDS;
+ const start = new Date(startSec * 1000);
+ const end = new Date(endSec * 1000);
+ // Show start in full, end as HH:MM:SS to save space.
+ const endTime = end.toISOString().slice(11, 19) + "Z";
+ return `${start.toISOString()} – ${endTime}`;
+}
+
+/**
+ * Format a fee charged value (in stroops as a string) into a readable string.
+ * e.g. "100" → "100 stroops (0.0000100 XLM)"
+ */
+export function formatFee(feeCharged: string): string {
+ try {
+ const stroops = BigInt(feeCharged);
+ const xlmWhole = stroops / STROOPS_PER_XLM;
+ const xlmFrac = String(stroops % STROOPS_PER_XLM).padStart(7, "0");
+ return `${feeCharged} stroops (${xlmWhole}.${xlmFrac} XLM)`;
+ } catch {
+ return `${feeCharged} stroops`;
+ }
+}
+
+/** Format a sorted list of operation types into a comma-separated string. */
+export function formatOpTypes(types: string[]): string {
+ if (types.length === 0) return "—";
+ return types.join(", ");
+}
+
+/** Human-readable label for a CandidateGroupState. */
+export function formatGroupState(state: CandidateGroupState): string {
+ const labels: Record = {
+ pending: "Pending review",
+ confirmed: "Confirmed duplicate",
+ dismissed: "Dismissed",
+ deferred: "Deferred",
+ };
+ return labels[state];
+}
+
+/** Format an ISO-8601 timestamp via the shared core formatter. */
+export function formatCreatedAt(isoTimestamp: string): string {
+ return formatDateTime(isoTimestamp);
+}
diff --git a/features/duplicate-transaction-scanner/lib/review.ts b/features/duplicate-transaction-scanner/lib/review.ts
new file mode 100644
index 0000000..248f9ef
--- /dev/null
+++ b/features/duplicate-transaction-scanner/lib/review.ts
@@ -0,0 +1,214 @@
+/**
+ * Merge review workflow for candidate duplicate groups.
+ *
+ * State machine
+ * -------------
+ * pending → confirm → confirmed (terminal, no further review)
+ * pending → dismiss → dismissed (terminal)
+ * pending → defer → deferred (reversible)
+ * deferred → confirm → confirmed
+ * deferred → dismiss → dismissed
+ * deferred → defer → deferred (idempotent)
+ * confirmed/dismissed → any → error "invalid_transition"
+ *
+ * Audit trail
+ * -----------
+ * Every successful transition emits a `record.state_changed` audit event via
+ * the core/audit module. Every refused transition emits `record.transition_denied`.
+ * These use the existing SENSITIVE_ACTIONS — no new core module changes needed.
+ *
+ * Persistence
+ * -----------
+ * Groups are persisted to localStorage under `GROUPS_STORAGE_KEY` so that
+ * review decisions survive page reloads. The store merges incoming scan results
+ * with existing decisions: a group already confirmed or dismissed is never
+ * overwritten by a fresh pending group from a new scan.
+ */
+
+import { recordAudit } from "@/core/audit";
+import { err, ok, type Result } from "@/core/result/result";
+import { measure } from "@/core/telemetry/telemetry";
+import type {
+ CandidateGroup,
+ CandidateGroupState,
+ DuplicateScanErrorCode,
+ ReviewAction,
+ ReviewInput,
+} from "@/features/duplicate-transaction-scanner/types";
+
+// ---------------------------------------------------------------------------
+// State machine
+// ---------------------------------------------------------------------------
+
+/** Legal (from, action) → to transitions. */
+const VALID_TRANSITIONS: Record<
+ CandidateGroupState,
+ Partial>
+> = {
+ pending: {
+ confirm: "confirmed",
+ dismiss: "dismissed",
+ defer: "deferred",
+ },
+ deferred: {
+ confirm: "confirmed",
+ dismiss: "dismissed",
+ defer: "deferred",
+ },
+ // Terminal states: no transitions allowed.
+ confirmed: {},
+ dismissed: {},
+};
+
+/** True iff no further review is possible from this state. */
+export function isTerminalState(state: CandidateGroupState): boolean {
+ return state === "confirmed" || state === "dismissed";
+}
+
+// ---------------------------------------------------------------------------
+// Persistence
+// ---------------------------------------------------------------------------
+
+export const GROUPS_STORAGE_KEY = "utix:dup-scan:v1:groups";
+
+function safeStorage(): Storage | null {
+ if (typeof window === "undefined") return null;
+ try {
+ window.localStorage.setItem("__test__", "1");
+ window.localStorage.removeItem("__test__");
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+/** Load all persisted groups from localStorage, or an empty list on error. */
+export function loadGroups(): CandidateGroup[] {
+ const storage = safeStorage();
+ if (!storage) return [];
+ try {
+ const raw = storage.getItem(GROUPS_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed as CandidateGroup[];
+ } catch {
+ return [];
+ }
+}
+
+/** Persist the full group list to localStorage. Silent on error. */
+export function saveGroups(groups: CandidateGroup[]): void {
+ const storage = safeStorage();
+ if (!storage) return;
+ try {
+ storage.setItem(GROUPS_STORAGE_KEY, JSON.stringify(groups));
+ } catch {
+ // Quota exceeded or private mode — gracefully degrade.
+ }
+}
+
+/**
+ * Merge fresh scan results (all pending) with the persisted store.
+ *
+ * Rules:
+ * - If a group id from the incoming scan already exists in the store with a
+ * terminal state (confirmed | dismissed), keep the stored version unchanged.
+ * - If a group id exists with a deferred state, keep the stored version
+ * (preserve the defer decision).
+ * - Otherwise (new pending group, or group re-appears after storage flush),
+ * take the incoming group.
+ * - Groups in the store that are NOT in the incoming scan are retained with
+ * their current state so historical decisions are not lost.
+ */
+export function mergeIntoStore(
+ incoming: CandidateGroup[],
+ stored: CandidateGroup[],
+): CandidateGroup[] {
+ const storedById = new Map(stored.map((g) => [g.id, g]));
+ const seen = new Set();
+
+ const result: CandidateGroup[] = incoming.map((incoming) => {
+ seen.add(incoming.id);
+ const existing = storedById.get(incoming.id);
+ // Preserve any existing review decision.
+ if (existing) return existing;
+ return incoming;
+ });
+
+ // Retain historical groups not present in this scan.
+ for (const [id, group] of storedById) {
+ if (!seen.has(id)) result.push(group);
+ }
+
+ // Sort: pending first, deferred, then resolved.
+ const ORDER: Record = {
+ pending: 0,
+ deferred: 1,
+ confirmed: 2,
+ dismissed: 3,
+ };
+ result.sort((a, b) => ORDER[a.state] - ORDER[b.state]);
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Review action
+// ---------------------------------------------------------------------------
+
+/**
+ * Apply a review action to one candidate group.
+ *
+ * Emits an audit event (`record.state_changed` or `record.transition_denied`)
+ * and wraps the whole operation in telemetry (`duplicate_scanner.review`).
+ */
+export function applyReview(
+ group: CandidateGroup,
+ input: ReviewInput,
+): Result {
+ return measure(
+ "duplicate_scanner.review",
+ { actorType: "user", correlationId: crypto.randomUUID() },
+ () => {
+ const nextState = VALID_TRANSITIONS[group.state]?.[input.action];
+
+ if (!nextState) {
+ // Refused transition — audit it and return an error.
+ recordAudit({
+ action: "record.transition_denied",
+ actor: { kind: "user", id: input.actorId },
+ scope: "own",
+ target: { kind: "duplicate_candidate_group", id: group.id },
+ reason: "invalid_transition",
+ outcome: "denied",
+ errorCode: "invalid_transition",
+ before: { state: group.state },
+ after: { requestedAction: input.action },
+ });
+ return err("invalid_transition" as DuplicateScanErrorCode);
+ }
+
+ const updatedGroup: CandidateGroup = {
+ ...group,
+ state: nextState,
+ reviewedAt: new Date().toISOString(),
+ reviewedBy: input.actorId,
+ reviewReason: input.reason,
+ };
+
+ // Audit the successful transition.
+ recordAudit({
+ action: "record.state_changed",
+ actor: { kind: "user", id: input.actorId },
+ scope: "own",
+ target: { kind: "duplicate_candidate_group", id: group.id },
+ reason: input.reason ?? `review.${input.action}`,
+ outcome: "allowed",
+ before: { state: group.state },
+ after: { state: nextState },
+ });
+
+ return ok(updatedGroup);
+ },
+ );
+}
diff --git a/features/duplicate-transaction-scanner/lib/scanner.errors.ts b/features/duplicate-transaction-scanner/lib/scanner.errors.ts
new file mode 100644
index 0000000..3c05184
--- /dev/null
+++ b/features/duplicate-transaction-scanner/lib/scanner.errors.ts
@@ -0,0 +1,13 @@
+import type { DuplicateScanErrorCode } from "@/features/duplicate-transaction-scanner/types";
+
+/** Map an HTTP status code (or 0 for a network-level failure) to our error code. */
+export function httpStatusToErrorCode(status: number): DuplicateScanErrorCode {
+ if (status === 404) return "not_found";
+ if (status === 429) return "rate_limited";
+ return "request_failed";
+}
+
+/** Map a thrown fetch error to our error code. */
+export function fetchErrorToErrorCode(_error: unknown): DuplicateScanErrorCode {
+ return "request_failed";
+}
diff --git a/features/duplicate-transaction-scanner/lib/scanner.ts b/features/duplicate-transaction-scanner/lib/scanner.ts
new file mode 100644
index 0000000..afbfda8
--- /dev/null
+++ b/features/duplicate-transaction-scanner/lib/scanner.ts
@@ -0,0 +1,144 @@
+/**
+ * Fetches a batch of Stellar transactions from Horizon, extracts their
+ * operation types, computes deterministic fingerprints, and groups candidate
+ * duplicates.
+ *
+ * Design notes
+ * ------------
+ * - Requests are sequential (one hash at a time) to stay within Horizon's
+ * per-IP rate limit. For up to 20 hashes the total wall-clock time is well
+ * under typical timeouts.
+ * - The entire scan is wrapped in `measure` so it shows up in telemetry as
+ * `duplicate_scanner.scan`.
+ * - Per-hash errors (404, 429) short-circuit the whole scan rather than
+ * silently dropping hashes. The caller sees the first error code encountered.
+ */
+
+import { HORIZON_URLS } from "@/core/network/config";
+import { err, ok, type Result } from "@/core/result/result";
+import { measure } from "@/core/telemetry/telemetry";
+import { groupByFingerprint } from "@/features/duplicate-transaction-scanner/lib/fingerprint";
+import {
+ fetchErrorToErrorCode,
+ httpStatusToErrorCode,
+} from "@/features/duplicate-transaction-scanner/lib/scanner.errors";
+import type {
+ DuplicateScanErrorCode,
+ DuplicateScanInput,
+ DuplicateScanResult,
+ FetchedTransaction,
+} from "@/features/duplicate-transaction-scanner/types";
+
+// ---------------------------------------------------------------------------
+// Horizon JSON shapes (minimal, only the fields we need)
+// ---------------------------------------------------------------------------
+
+interface HorizonTransaction {
+ hash: string;
+ source_account: string;
+ fee_charged: string;
+ memo_type: string;
+ memo?: string;
+ created_at: string;
+ ledger: number;
+ successful: boolean;
+}
+
+interface HorizonOperation {
+ type: string;
+}
+
+interface HorizonOperationsPage {
+ _embedded: { records: HorizonOperation[] };
+}
+
+// ---------------------------------------------------------------------------
+// Internal fetch helpers
+// ---------------------------------------------------------------------------
+
+async function fetchTransaction(
+ baseUrl: string,
+ hash: string,
+): Promise> {
+ try {
+ const res = await fetch(`${baseUrl}/transactions/${hash}`);
+ if (!res.ok) return err(httpStatusToErrorCode(res.status));
+ const data = await res.json() as HorizonTransaction;
+ return ok(data);
+ } catch (e) {
+ return err(fetchErrorToErrorCode(e));
+ }
+}
+
+async function fetchOperationTypes(
+ baseUrl: string,
+ hash: string,
+): Promise> {
+ try {
+ const res = await fetch(`${baseUrl}/transactions/${hash}/operations?limit=50`);
+ if (!res.ok) return err(httpStatusToErrorCode(res.status));
+ const page = await res.json() as HorizonOperationsPage;
+ const types = page._embedded.records.map((op) => op.type);
+ return ok(types);
+ } catch (e) {
+ return err(fetchErrorToErrorCode(e));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Scan a list of validated transaction hashes against Horizon and return
+ * candidate duplicate groups.
+ *
+ * @param input Validated hashes and network (from schema.ts).
+ * @param baseUrl Optional override for the Horizon base URL, used in tests.
+ */
+export async function scanForDuplicates(
+ input: DuplicateScanInput,
+ baseUrl?: string,
+): Promise> {
+ return measure(
+ "duplicate_scanner.scan",
+ { actorType: "user", correlationId: crypto.randomUUID() },
+ async () => {
+ const horizonBase =
+ baseUrl ?? HORIZON_URLS[input.network as "testnet" | "mainnet"] ?? HORIZON_URLS.testnet;
+
+ const fetched: FetchedTransaction[] = [];
+
+ for (const hash of input.hashes) {
+ const txResult = await fetchTransaction(horizonBase, hash);
+ if (!txResult.ok) return txResult;
+
+ const tx = txResult.value;
+ const opsResult = await fetchOperationTypes(horizonBase, hash);
+ if (!opsResult.ok) return opsResult;
+
+ fetched.push({
+ hash: tx.hash,
+ sourceAccount: tx.source_account,
+ feeCharged: tx.fee_charged,
+ memoType: tx.memo_type,
+ memo: tx.memo ?? "",
+ createdAt: tx.created_at,
+ operationTypes: opsResult.value.slice().sort(),
+ ledger: tx.ledger,
+ successful: tx.successful,
+ });
+ }
+
+ const groups = groupByFingerprint(fetched, input.network);
+
+ return ok({
+ scannedCount: fetched.length,
+ groupCount: groups.length,
+ groups,
+ network: input.network,
+ scannedAt: new Date().toISOString(),
+ });
+ },
+ );
+}
diff --git a/features/duplicate-transaction-scanner/manifest.ts b/features/duplicate-transaction-scanner/manifest.ts
new file mode 100644
index 0000000..2b81dc9
--- /dev/null
+++ b/features/duplicate-transaction-scanner/manifest.ts
@@ -0,0 +1,25 @@
+import { GitCompare } from "lucide-react";
+import type { FeatureManifest } from "@/core/registry/types";
+
+export const manifest: FeatureManifest = {
+ slug: "duplicate-transaction-scanner",
+ title: "Duplicate Transaction Scanner",
+ description:
+ "Detect duplicate Stellar transactions by deterministic fingerprint and review candidate groups with an audited confirm/dismiss/defer workflow.",
+ character:
+ "A vigilant lens sweeps transaction history, flagging identical fingerprints before they cause confusion.",
+ category: "transactions",
+ status: "working",
+ icon: GitCompare,
+ networks: ["testnet", "mainnet"],
+ keywords: [
+ "duplicate",
+ "transaction",
+ "fingerprint",
+ "merge",
+ "review",
+ "audit",
+ "scan",
+ "deduplication",
+ ],
+};
diff --git a/features/duplicate-transaction-scanner/msw/handlers.ts b/features/duplicate-transaction-scanner/msw/handlers.ts
new file mode 100644
index 0000000..fd44006
--- /dev/null
+++ b/features/duplicate-transaction-scanner/msw/handlers.ts
@@ -0,0 +1,53 @@
+import { http, HttpResponse } from "msw";
+import {
+ HASH_A1,
+ HASH_A2,
+ HASH_B1,
+ makeHorizonOperationsPage,
+ makeHorizonTransaction,
+ TX_A1,
+ TX_A2,
+ TX_B1,
+} from "@/features/duplicate-transaction-scanner/fixtures/scanner.fixture";
+
+const TESTNET = "https://horizon-testnet.stellar.org";
+
+export const handlers = [
+ // Transaction A1 — duplicate of A2
+ http.get(`${TESTNET}/transactions/${HASH_A1}`, () =>
+ HttpResponse.json(makeHorizonTransaction(TX_A1)),
+ ),
+ http.get(`${TESTNET}/transactions/${HASH_A1}/operations`, () =>
+ HttpResponse.json(makeHorizonOperationsPage(TX_A1.operationTypes)),
+ ),
+
+ // Transaction A2 — duplicate of A1
+ http.get(`${TESTNET}/transactions/${HASH_A2}`, () =>
+ HttpResponse.json(makeHorizonTransaction(TX_A2)),
+ ),
+ http.get(`${TESTNET}/transactions/${HASH_A2}/operations`, () =>
+ HttpResponse.json(makeHorizonOperationsPage(TX_A2.operationTypes)),
+ ),
+
+ // Transaction B1 — unique (no match)
+ http.get(`${TESTNET}/transactions/${HASH_B1}`, () =>
+ HttpResponse.json(makeHorizonTransaction(TX_B1)),
+ ),
+ http.get(`${TESTNET}/transactions/${HASH_B1}/operations`, () =>
+ HttpResponse.json(makeHorizonOperationsPage(TX_B1.operationTypes)),
+ ),
+];
+
+/** A1 transaction returns 404. */
+export const notFoundHandler = http.get(
+ `${TESTNET}/transactions/${HASH_A1}`,
+ () =>
+ HttpResponse.json({ title: "Resource Missing", status: 404 }, { status: 404 }),
+);
+
+/** A1 transaction returns 429. */
+export const rateLimitedHandler = http.get(
+ `${TESTNET}/transactions/${HASH_A1}`,
+ () =>
+ HttpResponse.json({ title: "Too Many Requests", status: 429 }, { status: 429 }),
+);
diff --git a/features/duplicate-transaction-scanner/panel.tsx b/features/duplicate-transaction-scanner/panel.tsx
new file mode 100644
index 0000000..bee48b2
--- /dev/null
+++ b/features/duplicate-transaction-scanner/panel.tsx
@@ -0,0 +1,5 @@
+import { DuplicateScannerPanel } from "@/features/duplicate-transaction-scanner/components/DuplicateScannerPanel";
+
+export default function Panel() {
+ return ;
+}
diff --git a/features/duplicate-transaction-scanner/schema.ts b/features/duplicate-transaction-scanner/schema.ts
new file mode 100644
index 0000000..7ad994a
--- /dev/null
+++ b/features/duplicate-transaction-scanner/schema.ts
@@ -0,0 +1,50 @@
+import { err, ok, type Result } from "@/core/result/result";
+import type {
+ DuplicateScanErrorCode,
+ DuplicateScanInput,
+} from "@/features/duplicate-transaction-scanner/types";
+
+/** Stellar transaction hashes are 32 bytes encoded as 64 lower-case hex chars. */
+const HASH_RE = /^[a-fA-F0-9]{64}$/;
+
+/** Maximum number of hashes accepted in one scan request. */
+export const MAX_HASHES = 20;
+
+/**
+ * Parse the raw textarea value into a list of normalised transaction hashes.
+ *
+ * Rules:
+ * - Empty or whitespace-only input → `empty_input`
+ * - More than MAX_HASHES lines → `too_many_hashes`
+ * - Any line that is not a valid 64-hex-char string → `invalid_hash`
+ * - Duplicates within the submitted list are silently deduplicated.
+ */
+export function parseDuplicateScanInput(
+ rawHashes: string,
+ network: string,
+): Result {
+ const trimmed = rawHashes.trim();
+ if (!trimmed) return err("empty_input");
+
+ const lines = trimmed
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .filter((l) => l.length > 0);
+
+ if (lines.length === 0) return err("empty_input");
+ if (lines.length > MAX_HASHES) return err("too_many_hashes");
+
+ const seen = new Set();
+ const hashes: string[] = [];
+
+ for (const line of lines) {
+ if (!HASH_RE.test(line)) return err("invalid_hash");
+ const normalised = line.toLowerCase();
+ if (!seen.has(normalised)) {
+ seen.add(normalised);
+ hashes.push(normalised);
+ }
+ }
+
+ return ok({ hashes, network });
+}
diff --git a/features/duplicate-transaction-scanner/types.ts b/features/duplicate-transaction-scanner/types.ts
new file mode 100644
index 0000000..c157be9
--- /dev/null
+++ b/features/duplicate-transaction-scanner/types.ts
@@ -0,0 +1,132 @@
+/**
+ * Types for the duplicate-transaction-scanner feature slice.
+ *
+ * The scanner accepts a batch of transaction hashes, fetches each one from
+ * Horizon, computes a deterministic fingerprint, and groups transactions that
+ * share the same fingerprint as candidate duplicates.
+ *
+ * Each candidate group then flows through a lightweight review workflow:
+ * a reviewer confirms (marks the group as a real duplicate), dismisses
+ * (marks the transactions as distinct despite the matching fingerprint) or
+ * defers (skips for now). Confirm and dismiss are terminal; defer is
+ * reversible on re-scan.
+ */
+
+// ---------------------------------------------------------------------------
+// Error codes
+// ---------------------------------------------------------------------------
+
+export type DuplicateScanErrorCode =
+ /** The input textarea was empty or contained only whitespace. */
+ | "empty_input"
+ /** More than MAX_HASHES hashes were supplied. */
+ | "too_many_hashes"
+ /** One or more of the supplied values is not a valid 64-hex-char hash. */
+ | "invalid_hash"
+ /** Horizon returned 404 for one of the hashes. */
+ | "not_found"
+ /** Horizon returned HTTP 429. */
+ | "rate_limited"
+ /** Any non-404, non-429 Horizon error. */
+ | "request_failed"
+ /** A review action was attempted on a group whose current state does not
+ * allow that transition. */
+ | "invalid_transition";
+
+// ---------------------------------------------------------------------------
+// Core domain types
+// ---------------------------------------------------------------------------
+
+/**
+ * The lifecycle states a candidate group can be in.
+ * - pending: newly detected, not yet reviewed
+ * - confirmed: reviewer confirmed these are real duplicates
+ * - dismissed: reviewer marked as distinct (false positive)
+ * - deferred: reviewer skipped; will re-appear on the next scan
+ */
+export type CandidateGroupState = "pending" | "confirmed" | "dismissed" | "deferred";
+
+/** The three review actions a user can take on a pending or deferred group. */
+export type ReviewAction = "confirm" | "dismiss" | "defer";
+
+/** A single transaction fetched from Horizon with the fields needed for
+ * fingerprinting. */
+export interface FetchedTransaction {
+ hash: string;
+ sourceAccount: string;
+ /** In stroops, as the string Horizon returns. */
+ feeCharged: string;
+ memoType: string;
+ /** The memo value, empty string if none. */
+ memo: string;
+ /** ISO-8601 string. */
+ createdAt: string;
+ /** Operation type strings, sorted lexicographically. */
+ operationTypes: string[];
+ ledger: number;
+ successful: boolean;
+}
+
+/**
+ * The pre-image that was hashed, plus the resulting hex digest.
+ * Storing the pre-image fields makes it easy to explain to a reviewer
+ * exactly *why* two transactions were grouped together.
+ */
+export interface DuplicateFingerprint {
+ /** Hex SHA-256 digest of the canonical JSON pre-image. */
+ value: string;
+ sourceAccount: string;
+ opTypesSorted: string[];
+ feeCharged: string;
+ memoType: string;
+ memo: string;
+ network: string;
+ /** Unix time / TIME_BUCKET_SECONDS (300 s = 5-minute windows). */
+ timeBucket: number;
+}
+
+/** A group of 2+ transactions that share the same fingerprint. */
+export interface CandidateGroup {
+ /** Equal to fingerprint.value — stable across re-scans. */
+ id: string;
+ fingerprint: DuplicateFingerprint;
+ transactions: FetchedTransaction[];
+ state: CandidateGroupState;
+ /** ISO-8601 instant of the last review action, if any. */
+ reviewedAt?: string;
+ /** Actor id of the reviewer (opaque string supplied by the caller). */
+ reviewedBy?: string;
+ /** Optional stable reason token supplied by the reviewer. */
+ reviewReason?: string;
+}
+
+/** Top-level result returned after a full scan. */
+export interface DuplicateScanResult {
+ scannedCount: number;
+ /** How many groups have 2+ matching transactions. */
+ groupCount: number;
+ /** Only groups with 2+ transactions — singletons are filtered out. */
+ groups: CandidateGroup[];
+ network: string;
+ /** ISO-8601 instant. */
+ scannedAt: string;
+}
+
+/** Validated input after schema parsing. */
+export interface DuplicateScanInput {
+ hashes: string[];
+ network: string;
+}
+
+/** Parameters for a single review action. */
+export interface ReviewInput {
+ groupId: string;
+ action: ReviewAction;
+ /** Optional stable reason token (must match /^[a-z0-9_.:-]{1,64}$/). */
+ reason?: string;
+ /**
+ * Caller-supplied actor identifier, e.g. a Stellar public key or a session
+ * id. Stored in the group's `reviewedBy` and in the audit trail.
+ */
+ actorId: string;
+}