From 996ab60082ce7db78ba273d0a70a6598ac6eaed1 Mon Sep 17 00:00:00 2001 From: Samet Date: Tue, 29 Sep 2026 15:42:38 +0300 Subject: [PATCH] feat(signature-hint-auditor): map envelope signature hints to public candidates --- features/signature-hint-auditor/README.md | 45 ++++++ .../SignatureHintAuditorPanel.test.tsx | 94 +++++++++++ .../__tests__/a11y.test.tsx | 38 +++++ .../__tests__/decorated-signatures.test.ts | 90 +++++++++++ .../__tests__/format.test.ts | 80 ++++++++++ .../__tests__/hint-candidates.test.ts | 119 ++++++++++++++ .../__tests__/schema.test.ts | 118 ++++++++++++++ .../__tests__/signatureHintAuditor.test.ts | 147 ++++++++++++++++++ .../__tests__/signer-hints.test.ts | 53 +++++++ .../useSignatureHintAuditor.test.tsx | 109 +++++++++++++ .../components/CandidateRows.tsx | 37 +++++ .../components/CollisionNotice.tsx | 26 ++++ .../SignatureHintAuditorEmptyState.tsx | 9 ++ .../components/SignatureHintAuditorForm.tsx | 65 ++++++++ .../components/SignatureHintAuditorPanel.tsx | 51 ++++++ .../components/SignatureHintAuditorResult.tsx | 68 ++++++++ .../components/SignatureRows.tsx | 76 +++++++++ features/signature-hint-auditor/copy.ts | 99 ++++++++++++ .../e2e/ambiguous-hint.spec.ts | 61 ++++++++ .../e2e/signature-hint-auditor.spec.ts | 70 +++++++++ .../fixtures/hint-collision.fixture.ts | 41 +++++ .../fixtures/signatureHintAuditor.fixture.ts | 122 +++++++++++++++ .../fixtures/signatures.fixture.ts | 27 ++++ .../hooks/useSignatureHintAuditor.ts | 57 +++++++ .../lib/decorated-signatures.ts | 76 +++++++++ features/signature-hint-auditor/lib/format.ts | 49 ++++++ .../lib/hint-candidates.ts | 79 ++++++++++ .../lib/signatureHintAuditor.errors.ts | 27 ++++ .../lib/signatureHintAuditor.ts | 50 ++++++ .../lib/signer-hints.ts | 43 +++++ features/signature-hint-auditor/manifest.ts | 27 ++++ .../signature-hint-auditor/msw/handlers.ts | 2 + features/signature-hint-auditor/panel.tsx | 1 + features/signature-hint-auditor/schema.ts | 78 ++++++++++ features/signature-hint-auditor/types.ts | 71 +++++++++ 35 files changed, 2205 insertions(+) create mode 100644 features/signature-hint-auditor/README.md create mode 100644 features/signature-hint-auditor/__tests__/SignatureHintAuditorPanel.test.tsx create mode 100644 features/signature-hint-auditor/__tests__/a11y.test.tsx create mode 100644 features/signature-hint-auditor/__tests__/decorated-signatures.test.ts create mode 100644 features/signature-hint-auditor/__tests__/format.test.ts create mode 100644 features/signature-hint-auditor/__tests__/hint-candidates.test.ts create mode 100644 features/signature-hint-auditor/__tests__/schema.test.ts create mode 100644 features/signature-hint-auditor/__tests__/signatureHintAuditor.test.ts create mode 100644 features/signature-hint-auditor/__tests__/signer-hints.test.ts create mode 100644 features/signature-hint-auditor/__tests__/useSignatureHintAuditor.test.tsx create mode 100644 features/signature-hint-auditor/components/CandidateRows.tsx create mode 100644 features/signature-hint-auditor/components/CollisionNotice.tsx create mode 100644 features/signature-hint-auditor/components/SignatureHintAuditorEmptyState.tsx create mode 100644 features/signature-hint-auditor/components/SignatureHintAuditorForm.tsx create mode 100644 features/signature-hint-auditor/components/SignatureHintAuditorPanel.tsx create mode 100644 features/signature-hint-auditor/components/SignatureHintAuditorResult.tsx create mode 100644 features/signature-hint-auditor/components/SignatureRows.tsx create mode 100644 features/signature-hint-auditor/copy.ts create mode 100644 features/signature-hint-auditor/e2e/ambiguous-hint.spec.ts create mode 100644 features/signature-hint-auditor/e2e/signature-hint-auditor.spec.ts create mode 100644 features/signature-hint-auditor/fixtures/hint-collision.fixture.ts create mode 100644 features/signature-hint-auditor/fixtures/signatureHintAuditor.fixture.ts create mode 100644 features/signature-hint-auditor/fixtures/signatures.fixture.ts create mode 100644 features/signature-hint-auditor/hooks/useSignatureHintAuditor.ts create mode 100644 features/signature-hint-auditor/lib/decorated-signatures.ts create mode 100644 features/signature-hint-auditor/lib/format.ts create mode 100644 features/signature-hint-auditor/lib/hint-candidates.ts create mode 100644 features/signature-hint-auditor/lib/signatureHintAuditor.errors.ts create mode 100644 features/signature-hint-auditor/lib/signatureHintAuditor.ts create mode 100644 features/signature-hint-auditor/lib/signer-hints.ts create mode 100644 features/signature-hint-auditor/manifest.ts create mode 100644 features/signature-hint-auditor/msw/handlers.ts create mode 100644 features/signature-hint-auditor/panel.tsx create mode 100644 features/signature-hint-auditor/schema.ts create mode 100644 features/signature-hint-auditor/types.ts diff --git a/features/signature-hint-auditor/README.md b/features/signature-hint-auditor/README.md new file mode 100644 index 0000000..233f7de --- /dev/null +++ b/features/signature-hint-auditor/README.md @@ -0,0 +1,45 @@ +# Transaction Signature Hint Auditor + +Decode a pasted transaction envelope offline and map every decorated signature +hint to optional public-key candidates — without treating a hint match as a +verified signature. + +## How it works + +The tool reads base64 transaction-envelope XDR with `@stellar/stellar-sdk` in +the browser. It walks every decorated signature in original order and records +the four-byte hint (lowercase hex). When you paste optional `G…` public keys, +each key's hint is derived the same way the network does — the last four bytes +of the raw public key — and compared to the envelope. + +A hint can match zero, one or many provided keys. Multiple matches are +collisions: every candidate is listed and none is preferred. Fee-bump envelopes +keep outer and inner signature vectors in separate groups so a fee-source hint +is never confused with an inner signer. + +## The non-obvious decision + +**A matching hint is labelled a hint match, never a verified signature.** Four +bytes are not a proof. Two unrelated keys can share them, and this tool will +happily show both as candidates. Cryptographic verification, threshold checks +and submission are all out of scope on purpose. + +Secret seeds (`S…`) are refused on the prefix alone — in the envelope field and +in the signer list — and the form is remounted so the seed does not sit in the +textarea. Input stays in memory for the session; `msw/handlers.ts` is empty. + +## Error outcomes + +| Code | Meaning | +| --- | --- | +| `empty_xdr` | Nothing was pasted in the envelope field | +| `invalid_xdr` | Not usable envelope XDR (including refused secret seeds) | +| `invalid_public_signer` | A signer token is not a valid `G…` public key | +| `unsupported_envelope` | Envelope discriminant is not classic v0/v1 or fee-bump | +| `too_many_signers` | More than 64 public keys in one paste | + +## Fixtures + +Envelopes are built from fixed raw seeds. The collision fixture forces two +genuine public keys to share a recorded hint so the ambiguous-hint UI path is +deterministic without hunting for a real on-chain collision. diff --git a/features/signature-hint-auditor/__tests__/SignatureHintAuditorPanel.test.tsx b/features/signature-hint-auditor/__tests__/SignatureHintAuditorPanel.test.tsx new file mode 100644 index 0000000..762f871 --- /dev/null +++ b/features/signature-hint-auditor/__tests__/SignatureHintAuditorPanel.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { renderFeature, screen } from "@/core/testing/render"; +import { SignatureHintAuditorPanel } from "@/features/signature-hint-auditor/components/SignatureHintAuditorPanel"; +import { copy, errorCopy, groupLabels } from "@/features/signature-hint-auditor/copy"; +import { + feeBumpXdr, + feeSource, + notBase64, + secretSeed, + signedClassicXdr, + source, + sourceHint, + unrelatedSigner +} from "@/features/signature-hint-auditor/fixtures/signatureHintAuditor.fixture"; +import { + collidingCandidates, + collisionHint +} from "@/features/signature-hint-auditor/fixtures/hint-collision.fixture"; +import { formatHint } from "@/features/signature-hint-auditor/lib/format"; + +type User = ReturnType["user"]; + +async function audit(user: User, envelope: string, publicSigners = "") { + await user.click(screen.getByLabelText(copy.envelopeLabel)); + await user.paste(envelope); + if (publicSigners) { + await user.click(screen.getByLabelText(copy.signersLabel)); + await user.paste(publicSigners); + } + await user.click(screen.getByRole("button", { name: copy.submit })); +} + +describe("SignatureHintAuditorPanel", () => { + it("shows the empty state first", () => { + renderFeature(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("lists a hint match without calling it verified", async () => { + const { user } = renderFeature(); + await audit(user, signedClassicXdr, source.publicKey()); + + expect(await screen.findByText(copy.resultTitle)).toBeInTheDocument(); + expect(screen.getByText(copy.disclaimer)).toBeInTheDocument(); + expect(screen.getByText(copy.oneCandidate)).toBeInTheDocument(); + expect(screen.getAllByText(formatHint(sourceHint)).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(copy.matchSingle)).toBeInTheDocument(); + }); + + it("separates fee-bump outer and inner groups", async () => { + const { user } = renderFeature(); + await audit(user, feeBumpXdr, `${feeSource.publicKey()}\n${source.publicKey()}`); + + expect(await screen.findByText(groupLabels.fee_bump_outer)).toBeInTheDocument(); + expect(screen.getByText(groupLabels.fee_bump_inner)).toBeInTheDocument(); + }); + + it("explains an unmatched hint", async () => { + const { user } = renderFeature(); + await audit(user, signedClassicXdr, unrelatedSigner.publicKey()); + + expect(await screen.findByText(copy.unmatchedLabel)).toBeInTheDocument(); + expect(screen.getByText(copy.collisionNoticeTitle)).toBeInTheDocument(); + }); + + it("shows actionable copy for invalid xdr", async () => { + const { user } = renderFeature(); + await audit(user, notBase64); + + expect(await screen.findByText(errorCopy.invalid_xdr.title)).toBeInTheDocument(); + expect(screen.getByText(errorCopy.invalid_xdr.description)).toBeInTheDocument(); + }); + + it("refuses a secret seed and clears it from the field", async () => { + const { user } = renderFeature(); + await audit(user, secretSeed); + + expect(await screen.findByText(errorCopy.invalid_xdr.title)).toBeInTheDocument(); + expect(screen.queryByDisplayValue(secretSeed)).not.toBeInTheDocument(); + }); + + it("states that input stays in memory only", async () => { + const { user } = renderFeature(); + await audit(user, signedClassicXdr); + + expect(await screen.findByText(copy.memoryNote)).toBeInTheDocument(); + }); + + it("documents colliding candidates share a forced hint in fixtures", () => { + expect(collidingCandidates).toHaveLength(2); + expect(collidingCandidates[0].hint).toBe(collisionHint); + expect(collidingCandidates[1].hint).toBe(collisionHint); + }); +}); diff --git a/features/signature-hint-auditor/__tests__/a11y.test.tsx b/features/signature-hint-auditor/__tests__/a11y.test.tsx new file mode 100644 index 0000000..1e94dc2 --- /dev/null +++ b/features/signature-hint-auditor/__tests__/a11y.test.tsx @@ -0,0 +1,38 @@ +import { describe, it } from "vitest"; +import { renderFeature, screen } from "@/core/testing/render"; +import { expectNoAxeViolations } from "@/core/testing/axe"; +import { SignatureHintAuditorPanel } from "@/features/signature-hint-auditor/components/SignatureHintAuditorPanel"; +import { copy, errorCopy } from "@/features/signature-hint-auditor/copy"; +import { + signedClassicXdr, + source +} from "@/features/signature-hint-auditor/fixtures/signatureHintAuditor.fixture"; + +describe("SignatureHintAuditorPanel accessibility", () => { + it("has no WCAG A/AA violations in its initial state", async () => { + const { container } = renderFeature(); + await expectNoAxeViolations(container); + }); + + it("has no WCAG A/AA violations with a successful audit on screen", async () => { + const { container, user } = renderFeature(); + + await user.click(screen.getByLabelText(copy.envelopeLabel)); + await user.paste(signedClassicXdr); + await user.click(screen.getByLabelText(copy.signersLabel)); + await user.paste(source.publicKey()); + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByText(copy.resultTitle); + + await expectNoAxeViolations(container); + }); + + it("has no WCAG A/AA violations while showing an error", async () => { + const { container, user } = renderFeature(); + + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByText(errorCopy.empty_xdr.title); + + await expectNoAxeViolations(container); + }); +}); diff --git a/features/signature-hint-auditor/__tests__/decorated-signatures.test.ts b/features/signature-hint-auditor/__tests__/decorated-signatures.test.ts new file mode 100644 index 0000000..e86b828 --- /dev/null +++ b/features/signature-hint-auditor/__tests__/decorated-signatures.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { decodeDecoratedSignatures } from "@/features/signature-hint-auditor/lib/decorated-signatures"; +import { + feeBumpXdr, + feeSourceHint, + multiSignedClassicXdr, + notAnEnvelopeXdr, + signedClassicXdr, + signedV0Xdr, + sourceHint, + unsignedClassicXdr, + unsignedFeeBumpXdr, + extraSignerHint +} from "@/features/signature-hint-auditor/fixtures/signatureHintAuditor.fixture"; + +describe("decodeDecoratedSignatures", () => { + it("lists classic v1 hints in envelope order", () => { + const result = decodeDecoratedSignatures(multiSignedClassicXdr); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.variant).toBe("classic-v1"); + expect(result.value.entries.map((entry) => entry.hint)).toEqual([ + sourceHint, + extraSignerHint + ]); + expect(result.value.entries.every((entry) => entry.group === "transaction")).toBe(true); + }); + + it("preserves zero-based indexes inside the group", () => { + const result = decodeDecoratedSignatures(multiSignedClassicXdr); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries.map((entry) => entry.index)).toEqual([0, 1]); + }); + + it("handles an empty signature vector", () => { + const result = decodeDecoratedSignatures(unsignedClassicXdr); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries).toEqual([]); + }); + + it("separates fee-bump outer and inner groups", () => { + const result = decodeDecoratedSignatures(feeBumpXdr); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.variant).toBe("fee-bump"); + expect(result.value.entries).toEqual([ + { index: 0, hint: feeSourceHint, group: "fee_bump_outer" }, + { index: 0, hint: sourceHint, group: "fee_bump_inner" } + ]); + }); + + it("keeps empty fee-bump groups as empty entries rather than inventing rows", () => { + const result = decodeDecoratedSignatures(unsignedFeeBumpXdr); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries).toEqual([]); + }); + + it("decodes classic v0 envelopes", () => { + const result = decodeDecoratedSignatures(signedV0Xdr); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.variant).toBe("classic-v0"); + expect(result.value.entries[0]?.hint).toBe(sourceHint); + }); + + it("rejects valid base64 that is not an envelope", () => { + expect(decodeDecoratedSignatures(notAnEnvelopeXdr)).toEqual({ + ok: false, + code: "invalid_xdr" + }); + }); + + it("returns a single signed classic hint", () => { + const result = decodeDecoratedSignatures(signedClassicXdr); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries).toHaveLength(1); + expect(result.value.entries[0]?.hint).toMatch(/^[0-9a-f]{8}$/); + }); +}); diff --git a/features/signature-hint-auditor/__tests__/format.test.ts b/features/signature-hint-auditor/__tests__/format.test.ts new file mode 100644 index 0000000..db7f7dd --- /dev/null +++ b/features/signature-hint-auditor/__tests__/format.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + formatCandidateCount, + formatEnvelopeVariant, + formatHint, + formatIndex, + formatMatchKind, + formatSignatureCount, + formatSignatureGroup +} from "@/features/signature-hint-auditor/lib/format"; +import { + isUnsupportedEnvelope, + toSignatureHintAuditorErrorCode +} from "@/features/signature-hint-auditor/lib/signatureHintAuditor.errors"; +import { copy } from "@/features/signature-hint-auditor/copy"; + +describe("formatHint", () => { + it("prefixes lowercase hex with 0x", () => { + expect(formatHint("aabbccdd")).toBe("0xaabbccdd"); + }); +}); + +describe("formatEnvelopeVariant", () => { + it("names every supported variant", () => { + expect(formatEnvelopeVariant("classic-v0")).toBe(copy.variantClassicV0); + expect(formatEnvelopeVariant("classic-v1")).toBe(copy.variantClassicV1); + expect(formatEnvelopeVariant("fee-bump")).toBe(copy.variantFeeBump); + }); +}); + +describe("formatSignatureGroup", () => { + it("labels classic and fee-bump groups distinctly", () => { + expect(formatSignatureGroup("transaction")).toBe(copy.groupTransaction); + expect(formatSignatureGroup("fee_bump_outer")).toBe(copy.groupOuter); + expect(formatSignatureGroup("fee_bump_inner")).toBe(copy.groupInner); + }); +}); + +describe("formatMatchKind", () => { + it("never calls a match a verified signature", () => { + expect(formatMatchKind("single")).toBe(copy.matchSingle); + expect(formatMatchKind("single")).not.toMatch(/verif/i); + expect(formatMatchKind("collision")).toBe(copy.matchCollision); + expect(formatMatchKind("none")).toBe(copy.matchNone); + }); +}); + +describe("formatSignatureCount", () => { + it("uses singular and plural forms", () => { + expect(formatSignatureCount(1)).toBe("1 signature"); + expect(formatSignatureCount(0)).toBe("0 signatures"); + expect(formatSignatureCount(2)).toBe("2 signatures"); + }); +}); + +describe("formatCandidateCount", () => { + it("explains zero, one and many candidates", () => { + expect(formatCandidateCount(0)).toBe(copy.noCandidates); + expect(formatCandidateCount(1)).toBe(copy.oneCandidate); + expect(formatCandidateCount(2)).toBe(copy.collisionLabel); + }); +}); + +describe("formatIndex", () => { + it("renders a readable index marker", () => { + expect(formatIndex(0)).toBe("#0"); + expect(formatIndex(3)).toBe("#3"); + }); +}); + +describe("error classification", () => { + it("maps unexpected throws to invalid_xdr", () => { + expect(toSignatureHintAuditorErrorCode(new Error("boom"))).toBe("invalid_xdr"); + }); + + it("flags unsupported envelopes for an informational notice", () => { + expect(isUnsupportedEnvelope("unsupported_envelope")).toBe(true); + expect(isUnsupportedEnvelope("invalid_xdr")).toBe(false); + }); +}); diff --git a/features/signature-hint-auditor/__tests__/hint-candidates.test.ts b/features/signature-hint-auditor/__tests__/hint-candidates.test.ts new file mode 100644 index 0000000..291e0f1 --- /dev/null +++ b/features/signature-hint-auditor/__tests__/hint-candidates.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + buildGroupReports, + candidatesForHint, + countCollisions, + countUnmatched, + matchDecoratedHint +} from "@/features/signature-hint-auditor/lib/hint-candidates"; +import { + collidingCandidates, + collisionHint, + distinctCandidate +} from "@/features/signature-hint-auditor/fixtures/hint-collision.fixture"; +import { + extraSigner, + extraSignerHint, + source, + sourceHint +} from "@/features/signature-hint-auditor/fixtures/signatureHintAuditor.fixture"; +import type { DecoratedSignatureEntry, PublicSignerHint } from "@/features/signature-hint-auditor/types"; + +const sourceCandidate: PublicSignerHint = { + publicKey: source.publicKey(), + hint: sourceHint +}; + +const extraCandidate: PublicSignerHint = { + publicKey: extraSigner.publicKey(), + hint: extraSignerHint +}; + +describe("candidatesForHint", () => { + it("returns zero candidates when nothing matches", () => { + expect(candidatesForHint(sourceHint, [extraCandidate])).toEqual([]); + }); + + it("returns a single candidate for an exact hint match", () => { + expect(candidatesForHint(sourceHint, [sourceCandidate, extraCandidate])).toEqual([ + source.publicKey() + ]); + }); + + it("returns every colliding candidate without picking a winner", () => { + expect(candidatesForHint(collisionHint, collidingCandidates)).toEqual([ + collidingCandidates[0].publicKey, + collidingCandidates[1].publicKey + ]); + }); +}); + +describe("matchDecoratedHint", () => { + const entry: DecoratedSignatureEntry = { + index: 0, + hint: sourceHint, + group: "transaction" + }; + + it("labels a miss as none", () => { + expect(matchDecoratedHint(entry, [extraCandidate]).matchKind).toBe("none"); + }); + + it("labels one match as single", () => { + expect(matchDecoratedHint(entry, [sourceCandidate]).matchKind).toBe("single"); + }); + + it("labels multiple matches as collision", () => { + expect( + matchDecoratedHint( + { index: 0, hint: collisionHint, group: "transaction" }, + collidingCandidates + ).matchKind + ).toBe("collision"); + }); +}); + +describe("buildGroupReports", () => { + it("keeps fee-bump outer and inner groups apart", () => { + const entries: DecoratedSignatureEntry[] = [ + { index: 0, hint: "aaaaaaaa", group: "fee_bump_outer" }, + { index: 0, hint: sourceHint, group: "fee_bump_inner" } + ]; + + const reports = buildGroupReports(entries, [sourceCandidate], [ + "fee_bump_outer", + "fee_bump_inner" + ]); + + expect(reports.map((report) => report.group)).toEqual([ + "fee_bump_outer", + "fee_bump_inner" + ]); + expect(reports[1]?.signatures[0]?.matchKind).toBe("single"); + expect(reports[0]?.signatures[0]?.matchKind).toBe("none"); + }); + + it("still lists an empty transaction group for unsigned classic envelopes", () => { + const reports = buildGroupReports([], [sourceCandidate], ["transaction"]); + + expect(reports).toEqual([{ group: "transaction", signatures: [] }]); + }); +}); + +describe("collision counters", () => { + it("counts collisions and unmatched rows", () => { + const matches = [ + matchDecoratedHint( + { index: 0, hint: collisionHint, group: "transaction" }, + collidingCandidates + ), + matchDecoratedHint( + { index: 1, hint: distinctCandidate.hint, group: "transaction" }, + collidingCandidates + ) + ]; + + expect(countCollisions(matches)).toBe(1); + expect(countUnmatched(matches)).toBe(1); + }); +}); diff --git a/features/signature-hint-auditor/__tests__/schema.test.ts b/features/signature-hint-auditor/__tests__/schema.test.ts new file mode 100644 index 0000000..640e53a --- /dev/null +++ b/features/signature-hint-auditor/__tests__/schema.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_ENVELOPE_LENGTH, + MAX_PUBLIC_SIGNERS, + parsePublicSigners, + parseSignatureHintAuditorInput +} from "@/features/signature-hint-auditor/schema"; +import { + notBase64, + secretSeed, + signedClassicXdr, + source, + extraSigner +} from "@/features/signature-hint-auditor/fixtures/signatureHintAuditor.fixture"; + +describe("parseSignatureHintAuditorInput", () => { + it("rejects an empty envelope as empty_xdr", () => { + expect(parseSignatureHintAuditorInput({ envelope: " \n ", publicSigners: "" })).toEqual({ + ok: false, + code: "empty_xdr" + }); + }); + + it("rejects text that is not base64 as invalid_xdr", () => { + expect( + parseSignatureHintAuditorInput({ envelope: notBase64, publicSigners: "" }) + ).toEqual({ ok: false, code: "invalid_xdr" }); + }); + + it("rejects base64 whose length is not a multiple of four", () => { + expect(parseSignatureHintAuditorInput({ envelope: "AAAAA", publicSigners: "" })).toEqual({ + ok: false, + code: "invalid_xdr" + }); + }); + + it("refuses a secret seed in the envelope field and tags it for redaction", () => { + const result = parseSignatureHintAuditorInput({ + envelope: secretSeed, + publicSigners: "" + }); + + expect(result).toEqual({ ok: false, code: "invalid_xdr", detail: "secret_key" }); + expect(JSON.stringify(result)).not.toContain(secretSeed); + }); + + it("accepts an envelope with no public signers", () => { + const result = parseSignatureHintAuditorInput({ + envelope: signedClassicXdr, + publicSigners: " " + }); + + expect(result.ok && result.value).toEqual({ + envelope: signedClassicXdr, + publicSigners: [] + }); + }); + + it("strips whitespace from a wrapped envelope paste", () => { + const wrapped = `${signedClassicXdr.slice(0, 40)}\n ${signedClassicXdr.slice(40)}`; + const result = parseSignatureHintAuditorInput({ + envelope: wrapped, + publicSigners: "" + }); + + expect(result.ok && result.value.envelope).toBe(signedClassicXdr); + }); + + it("rejects an envelope past the length cap", () => { + const pastCap = "A".repeat(MAX_ENVELOPE_LENGTH + 4); + expect(parseSignatureHintAuditorInput({ envelope: pastCap, publicSigners: "" })).toEqual({ + ok: false, + code: "invalid_xdr" + }); + }); + + it("accepts public keys separated by newlines and commas", () => { + const result = parseSignatureHintAuditorInput({ + envelope: signedClassicXdr, + publicSigners: `${source.publicKey()},\n${extraSigner.publicKey()}` + }); + + expect(result.ok && result.value.publicSigners).toEqual([ + source.publicKey(), + extraSigner.publicKey() + ]); + }); +}); + +describe("parsePublicSigners", () => { + it("rejects a secret seed as invalid_public_signer with redaction", () => { + expect(parsePublicSigners(secretSeed)).toEqual({ + ok: false, + code: "invalid_public_signer", + detail: "secret_key" + }); + }); + + it("rejects a malformed public key", () => { + expect(parsePublicSigners("GNOTAKEY")).toEqual({ + ok: false, + code: "invalid_public_signer" + }); + }); + + it("rejects more than the documented signer cap", () => { + const keys = Array.from({ length: MAX_PUBLIC_SIGNERS + 1 }, () => source.publicKey()); + expect(parsePublicSigners(keys.join("\n"))).toEqual({ + ok: false, + code: "too_many_signers" + }); + }); + + it("accepts exactly the documented signer cap", () => { + const keys = Array.from({ length: MAX_PUBLIC_SIGNERS }, () => source.publicKey()); + expect(parsePublicSigners(keys.join("\n")).ok).toBe(true); + }); +}); diff --git a/features/signature-hint-auditor/__tests__/signatureHintAuditor.test.ts b/features/signature-hint-auditor/__tests__/signatureHintAuditor.test.ts new file mode 100644 index 0000000..b6205b7 --- /dev/null +++ b/features/signature-hint-auditor/__tests__/signatureHintAuditor.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { auditSignatureHints } from "@/features/signature-hint-auditor/lib/signatureHintAuditor"; +import { + countCollisions, + matchDecoratedHint +} from "@/features/signature-hint-auditor/lib/hint-candidates"; +import { parseSignatureHintAuditorInput } from "@/features/signature-hint-auditor/schema"; +import { + collidingCandidates, + collisionEnvelopeXdr, + collisionHint +} from "@/features/signature-hint-auditor/fixtures/hint-collision.fixture"; +import { + feeBumpXdr, + feeSource, + multiSignedClassicXdr, + notAnEnvelopeXdr, + signedClassicXdr, + source, + sourceHint, + unrelatedSigner, + unsignedClassicXdr, + extraSigner +} from "@/features/signature-hint-auditor/fixtures/signatureHintAuditor.fixture"; +import { + matchingPublicKeys, + multiSignatureEnvelopeXdr +} from "@/features/signature-hint-auditor/fixtures/signatures.fixture"; + +function audit(envelope: string, publicSigners: string[] = []) { + const parsed = parseSignatureHintAuditorInput({ + envelope, + publicSigners: publicSigners.join("\n") + }); + if (!parsed.ok) throw new Error(`fixture failed to parse: ${parsed.code}`); + return auditSignatureHints(parsed.value); +} + +describe("auditSignatureHints", () => { + it("matches a single provided public key to its hint", () => { + const result = audit(signedClassicXdr, [source.publicKey()]); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.signatureCount).toBe(1); + expect(result.value.groups[0]?.signatures[0]).toMatchObject({ + hint: sourceHint, + matchKind: "single", + candidates: [source.publicKey()] + }); + expect(result.value.unmatchedCount).toBe(0); + expect(result.value.collisionCount).toBe(0); + }); + + it("leaves every hint unmatched when no public keys are provided", () => { + const result = audit(signedClassicXdr); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.providedSigners).toEqual([]); + expect(result.value.unmatchedCount).toBe(1); + expect(result.value.groups[0]?.signatures[0]?.matchKind).toBe("none"); + }); + + it("keeps multi-signature hints in envelope order", () => { + const result = audit(multiSignatureEnvelopeXdr, matchingPublicKeys); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.groups[0]?.signatures.map((row) => row.matchKind)).toEqual([ + "single", + "single" + ]); + expect(result.value.groups[0]?.signatures[0]?.candidates[0]).toBe(matchingPublicKeys[0]); + expect(result.value.groups[0]?.signatures[1]?.candidates[0]).toBe(matchingPublicKeys[1]); + }); + + it("separates fee-bump outer and inner signature groups", () => { + const result = audit(feeBumpXdr, [feeSource.publicKey(), source.publicKey()]); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.variant).toBe("fee-bump"); + expect(result.value.groups.map((group) => group.group)).toEqual([ + "fee_bump_outer", + "fee_bump_inner" + ]); + expect(result.value.groups[0]?.signatures[0]?.matchKind).toBe("single"); + expect(result.value.groups[1]?.signatures[0]?.matchKind).toBe("single"); + }); + + it("reports an unmatched hint when the wrong public key is provided", () => { + const result = audit(signedClassicXdr, [unrelatedSigner.publicKey()]); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.unmatchedCount).toBe(1); + expect(result.value.groups[0]?.signatures[0]?.candidates).toEqual([]); + }); + + it("handles an empty signature vector", () => { + const result = audit(unsignedClassicXdr, [source.publicKey()]); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.signatureCount).toBe(0); + expect(result.value.groups[0]?.signatures).toEqual([]); + }); + + it("rejects undecodable envelope bytes", () => { + expect(audit(notAnEnvelopeXdr)).toEqual({ ok: false, code: "invalid_xdr" }); + }); + + it("surfaces collisions when two candidates share a hint", () => { + const decoded = audit(collisionEnvelopeXdr); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + + const forcedMatch = matchDecoratedHint( + { + index: 0, + hint: collisionHint, + group: "transaction" + }, + collidingCandidates + ); + + expect(forcedMatch.matchKind).toBe("collision"); + expect(forcedMatch.candidates).toHaveLength(2); + expect(countCollisions([forcedMatch])).toBe(1); + expect(decoded.value.groups[0]?.signatures[0]?.hint).toBe(collisionHint); + }); + + it("still matches when an extra unrelated key is present", () => { + const result = audit(multiSignedClassicXdr, [ + source.publicKey(), + extraSigner.publicKey(), + unrelatedSigner.publicKey() + ]); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.collisionCount).toBe(0); + expect(result.value.unmatchedCount).toBe(0); + expect(result.value.providedSigners).toHaveLength(3); + }); +}); diff --git a/features/signature-hint-auditor/__tests__/signer-hints.test.ts b/features/signature-hint-auditor/__tests__/signer-hints.test.ts new file mode 100644 index 0000000..0f26819 --- /dev/null +++ b/features/signature-hint-auditor/__tests__/signer-hints.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { deriveSignerHint, deriveSignerHints } from "@/features/signature-hint-auditor/lib/signer-hints"; +import { + extraSigner, + extraSignerHint, + secretSeed, + source, + sourceHint +} from "@/features/signature-hint-auditor/fixtures/signatureHintAuditor.fixture"; + +describe("deriveSignerHint", () => { + it("derives the last four bytes of the public key as hex", () => { + expect(deriveSignerHint(source.publicKey())).toEqual({ + ok: true, + value: { publicKey: source.publicKey(), hint: sourceHint } + }); + }); + + it("rejects a secret seed", () => { + expect(deriveSignerHint(secretSeed)).toEqual({ + ok: false, + code: "invalid_public_signer" + }); + }); + + it("rejects a non-key string", () => { + expect(deriveSignerHint("not-a-key")).toEqual({ + ok: false, + code: "invalid_public_signer" + }); + }); +}); + +describe("deriveSignerHints", () => { + it("preserves paste order across multiple keys", () => { + const result = deriveSignerHints([extraSigner.publicKey(), source.publicKey()]); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.map((entry) => entry.hint)).toEqual([extraSignerHint, sourceHint]); + }); + + it("fails closed on the first invalid key", () => { + expect(deriveSignerHints([source.publicKey(), "GBAD"])).toEqual({ + ok: false, + code: "invalid_public_signer" + }); + }); + + it("accepts an empty list", () => { + expect(deriveSignerHints([])).toEqual({ ok: true, value: [] }); + }); +}); diff --git a/features/signature-hint-auditor/__tests__/useSignatureHintAuditor.test.tsx b/features/signature-hint-auditor/__tests__/useSignatureHintAuditor.test.tsx new file mode 100644 index 0000000..0d63626 --- /dev/null +++ b/features/signature-hint-auditor/__tests__/useSignatureHintAuditor.test.tsx @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useSignatureHintAuditor } from "@/features/signature-hint-auditor/hooks/useSignatureHintAuditor"; +import { + feeBumpXdr, + notBase64, + secretSeed, + signedClassicXdr, + source +} from "@/features/signature-hint-auditor/fixtures/signatureHintAuditor.fixture"; + +describe("useSignatureHintAuditor", () => { + it("starts idle", () => { + const { result } = renderHook(() => useSignatureHintAuditor()); + expect(result.current.state).toEqual({ status: "idle" }); + }); + + it("reports a successful audit", () => { + const { result } = renderHook(() => useSignatureHintAuditor()); + + act(() => + result.current.submit({ + envelope: signedClassicXdr, + publicSigners: source.publicKey() + }) + ); + + expect(result.current.state.status).toBe("success"); + expect(result.current.state).toMatchObject({ + result: { signatureCount: 1, collisionCount: 0 } + }); + }); + + it("surfaces empty_xdr for a blank envelope", () => { + const { result } = renderHook(() => useSignatureHintAuditor()); + + act(() => result.current.submit({ envelope: " ", publicSigners: "" })); + + expect(result.current.state).toEqual({ status: "error", code: "empty_xdr" }); + }); + + it("surfaces invalid_xdr for a bad paste", () => { + const { result } = renderHook(() => useSignatureHintAuditor()); + + act(() => result.current.submit({ envelope: notBase64, publicSigners: "" })); + + expect(result.current.state).toEqual({ status: "error", code: "invalid_xdr" }); + }); + + it("surfaces invalid_public_signer for a bad key list", () => { + const { result } = renderHook(() => useSignatureHintAuditor()); + + act(() => + result.current.submit({ envelope: signedClassicXdr, publicSigners: "GNOTAKEY" }) + ); + + expect(result.current.state).toEqual({ + status: "error", + code: "invalid_public_signer" + }); + }); + + it("never keeps a pasted secret in hook state, and asks for a redaction", () => { + const { result } = renderHook(() => useSignatureHintAuditor()); + + expect(result.current.redactions).toBe(0); + + act(() => result.current.submit({ envelope: secretSeed, publicSigners: "" })); + + expect(result.current.state).toEqual({ status: "error", code: "invalid_xdr" }); + expect(JSON.stringify(result.current.state)).not.toContain(secretSeed); + expect(result.current.redactions).toBe(1); + }); + + it("redacts when a secret appears in the signer field", () => { + const { result } = renderHook(() => useSignatureHintAuditor()); + + act(() => + result.current.submit({ envelope: signedClassicXdr, publicSigners: secretSeed }) + ); + + expect(result.current.state).toEqual({ + status: "error", + code: "invalid_public_signer" + }); + expect(result.current.redactions).toBe(1); + }); + + it("replaces a previous result rather than leaving it on screen", () => { + const { result } = renderHook(() => useSignatureHintAuditor()); + + act(() => + result.current.submit({ envelope: signedClassicXdr, publicSigners: "" }) + ); + expect(result.current.state.status).toBe("success"); + + act(() => result.current.submit({ envelope: notBase64, publicSigners: "" })); + expect(result.current.state.status).toBe("error"); + }); + + it("returns to idle on reset", () => { + const { result } = renderHook(() => useSignatureHintAuditor()); + + act(() => result.current.submit({ envelope: feeBumpXdr, publicSigners: "" })); + act(() => result.current.reset()); + + expect(result.current.state).toEqual({ status: "idle" }); + }); +}); diff --git a/features/signature-hint-auditor/components/CandidateRows.tsx b/features/signature-hint-auditor/components/CandidateRows.tsx new file mode 100644 index 0000000..780a405 --- /dev/null +++ b/features/signature-hint-auditor/components/CandidateRows.tsx @@ -0,0 +1,37 @@ +import { CopyableValue } from "@/core/ui/CopyableValue"; +import { copy } from "@/features/signature-hint-auditor/copy"; +import { formatHint } from "@/features/signature-hint-auditor/lib/format"; +import type { PublicSignerHint } from "@/features/signature-hint-auditor/types"; + +/** Lists optional public keys and the hints derived from them. */ +export function CandidateRows({ signers }: { signers: PublicSignerHint[] }) { + if (!signers.length) { + return

{copy.noSignersProvided}

; + } + + return ( +
    + {signers.map((signer) => ( +
  • +
    +
    +

    + {copy.labelSignerKey} +

    + +
    +
    +

    + {copy.labelSignerHint} +

    +

    {formatHint(signer.hint)}

    +
    +
    +
  • + ))} +
+ ); +} diff --git a/features/signature-hint-auditor/components/CollisionNotice.tsx b/features/signature-hint-auditor/components/CollisionNotice.tsx new file mode 100644 index 0000000..fff4777 --- /dev/null +++ b/features/signature-hint-auditor/components/CollisionNotice.tsx @@ -0,0 +1,26 @@ +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { copy } from "@/features/signature-hint-auditor/copy"; + +/** + * Explains ambiguous and missing hints once per result. + * + * Shown whenever any decorated hint is unmatched or collides so the user is + * never left wondering why a row has zero or many candidates. + */ +export function CollisionNotice({ + collisionCount, + unmatchedCount +}: { + collisionCount: number; + unmatchedCount: number; +}) { + if (collisionCount === 0 && unmatchedCount === 0) return null; + + return ( + + ); +} diff --git a/features/signature-hint-auditor/components/SignatureHintAuditorEmptyState.tsx b/features/signature-hint-auditor/components/SignatureHintAuditorEmptyState.tsx new file mode 100644 index 0000000..01c0089 --- /dev/null +++ b/features/signature-hint-auditor/components/SignatureHintAuditorEmptyState.tsx @@ -0,0 +1,9 @@ +import { Fingerprint } from "lucide-react"; +import { EmptyState } from "@/core/ui/EmptyState"; +import { copy } from "@/features/signature-hint-auditor/copy"; + +export function SignatureHintAuditorEmptyState() { + return ( + + ); +} diff --git a/features/signature-hint-auditor/components/SignatureHintAuditorForm.tsx b/features/signature-hint-auditor/components/SignatureHintAuditorForm.tsx new file mode 100644 index 0000000..8998770 --- /dev/null +++ b/features/signature-hint-auditor/components/SignatureHintAuditorForm.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Button } from "@/core/ui/Button"; +import { Field } from "@/core/ui/Field"; +import { Textarea } from "@/core/ui/Input"; +import { copy } from "@/features/signature-hint-auditor/copy"; +import type { RawSignatureHintAuditorInput } from "@/features/signature-hint-auditor/schema"; + +export function SignatureHintAuditorForm({ + onSubmit, + pending = false +}: { + onSubmit: (input: RawSignatureHintAuditorInput) => void; + pending?: boolean; +}) { + const [envelope, setEnvelope] = useState(""); + const [publicSigners, setPublicSigners] = useState(""); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSubmit({ envelope, publicSigners }); + } + + return ( +
+ + {({ inputId, describedBy, invalid, required }) => ( +