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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/__tests__/components/TransferAdminModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { StrKey } from "@stellar/stellar-sdk";
import TransferAdminModal from "@/components/TransferAdminModal";

// The modal reads a few labels from the "Admin" namespace; everything else
// arrives via props. Mirror the real translations so assertions read naturally.
jest.mock("next-intl", () => ({
useTranslations: () => (key: string) => {
const map: Record<string, string> = {
requiredWord: "CONFIRM",
invalidAddress: "Invalid address",
newAdminAddress: "New Admin Address",
transferring: "Transferring…",
};
return map[key] ?? key;
},
}));

// A guaranteed well-formed Stellar public key (correct prefix, length, checksum).
const VALID_ADDRESS = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 7));
const INVALID_ADDRESS = "GNOTAREALSTELLARADDRESS";

const baseProps = {
isOpen: true,
isTransferring: false,
onClose: jest.fn(),
title: "Transfer admin",
body: "Confirm the transfer.",
confirmLabel: "Type CONFIRM to proceed",
typeConfirmPlaceholder: "CONFIRM",
cancelLabel: "Cancel",
confirmButtonLabel: "Transfer",
};

describe("TransferAdminModal address validation", () => {
it("accepts a valid Stellar public key: no error, submit enabled after typing CONFIRM", () => {
const onConfirm = jest.fn();
render(
<TransferAdminModal {...baseProps} onConfirm={onConfirm} newAdminAddress={VALID_ADDRESS} />,
);

expect(screen.queryByText("Invalid address")).not.toBeInTheDocument();

const confirmButton = screen.getByRole("button", { name: "Transfer" });
expect(confirmButton).toBeDisabled();

fireEvent.change(screen.getByPlaceholderText("CONFIRM"), { target: { value: "CONFIRM" } });
expect(confirmButton).toBeEnabled();

fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledTimes(1);
});

it("rejects an invalid address: shows an inline error and keeps submit disabled", () => {
const onConfirm = jest.fn();
render(
<TransferAdminModal {...baseProps} onConfirm={onConfirm} newAdminAddress={INVALID_ADDRESS} />,
);

expect(screen.getByRole("alert")).toHaveTextContent("Invalid address");

const confirmButton = screen.getByRole("button", { name: "Transfer" });
fireEvent.change(screen.getByPlaceholderText("CONFIRM"), { target: { value: "CONFIRM" } });
expect(confirmButton).toBeDisabled();

fireEvent.click(confirmButton);
expect(onConfirm).not.toHaveBeenCalled();
});
});
15 changes: 15 additions & 0 deletions src/__tests__/unit/validators.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { StrKey } from "@stellar/stellar-sdk";
import {
validateStellarAddress,
isValidStellarPublicKey,
validateAmount,
validateFundingGoal,
validateDuration,
Expand All @@ -19,6 +21,19 @@ describe("Validators", () => {
expect(() => validateStellarAddress("G" + "A".repeat(55))).not.toThrow();
});

it("isValidStellarPublicKey", () => {
const valid = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 7));
expect(isValidStellarPublicKey(valid)).toBe(true);
// Tolerates surrounding whitespace from copy/paste.
expect(isValidStellarPublicKey(` ${valid} `)).toBe(true);

// Wrong prefix, too short, bad checksum, and empty all fail.
expect(isValidStellarPublicKey("")).toBe(false);
expect(isValidStellarPublicKey("GABC")).toBe(false);
expect(isValidStellarPublicKey("G" + "A".repeat(55))).toBe(false);
expect(isValidStellarPublicKey("MNOTAVALIDADDRESS")).toBe(false);
});

it("validateAmount", () => {
expect(() => validateAmount(0)).toThrow(
new ContractErrorException(ContractError.ContributionMustBePositive),
Expand Down
12 changes: 11 additions & 1 deletion src/components/TransferAdminModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { isValidStellarPublicKey } from "@/utils/validators";

interface TransferAdminModalProps {
newAdminAddress?: string;
Expand Down Expand Up @@ -44,7 +45,11 @@ export default function TransferAdminModal({
const [confirmInput, setConfirmInput] = useState("");

const requiredWord = t("requiredWord");
const canConfirm = confirmInput.trim() === requiredWord;
// When an address is supplied, it must be a well-formed Stellar public key
// before submit is allowed. This catches invalid input client-side instead of
// failing only after a Freighter signature prompt and a wasted transaction.
const isAddressValid = !newAdminAddress || isValidStellarPublicKey(newAdminAddress);
const canConfirm = confirmInput.trim() === requiredWord && isAddressValid;

// Reset input and focus when modal opens
useEffect(() => {
Expand Down Expand Up @@ -114,6 +119,11 @@ export default function TransferAdminModal({
</p>
</div>
) : null}
{newAdminAddress && !isAddressValid ? (
<p role="alert" className="text-sm font-medium text-red-600 dark:text-red-400">
{t("invalidAddress")}
</p>
) : null}
<div>
<label
htmlFor="confirm-input"
Expand Down
13 changes: 13 additions & 0 deletions src/utils/validators.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { StrKey } from "@stellar/stellar-sdk";
import { ContractError, ContractErrorException } from "./contractErrors";

export function validateStellarAddress(
Expand All @@ -9,6 +10,18 @@ export function validateStellarAddress(
}
}

/**
* Non-throwing check that `address` is a syntactically valid Stellar ed25519
* public key — starts with `G`, has the correct length, and a valid StrKey
* checksum. Intended for client-side form validation where an inline error is
* shown rather than an exception thrown (see TransferAdminModal). Unlike
* {@link validateStellarAddress}, this verifies the checksum, not just the
* length and prefix.
*/
export function isValidStellarPublicKey(address: string): boolean {
return typeof address === "string" && StrKey.isValidEd25519PublicKey(address.trim());
}

export function validateAmount(amount: number | bigint): void {
if (Number(amount) <= 0) {
throw new ContractErrorException(ContractError.ContributionMustBePositive);
Expand Down
Loading