diff --git a/react/src/screens/BurnScreen.test.tsx b/react/src/screens/BurnScreen.test.tsx index e4e067e7..5917866d 100644 --- a/react/src/screens/BurnScreen.test.tsx +++ b/react/src/screens/BurnScreen.test.tsx @@ -43,6 +43,11 @@ describe("BurnScreen", () => { sessionStorage.clear(); }); + const confirmBurn = () => { + fireEvent.click(screen.getByTestId("confirmation-dialog-checkbox")); + fireEvent.click(screen.getByTestId("confirmation-dialog-confirm")); + }; + const openDialog = (amount: string) => { fireEvent.change(screen.getByLabelText("Amount"), { target: { value: amount }, @@ -50,11 +55,6 @@ describe("BurnScreen", () => { fireEvent.click(screen.getByTestId("burn-submit")); }; - const confirmBurn = () => { - fireEvent.click(screen.getByTestId("confirmation-dialog-checkbox")); - fireEvent.click(screen.getByTestId("confirmation-dialog-confirm")); - }; - it("shows the connected balance and disables submit above it", () => { mockUseBalance.mockReturnValue({ data: 500n, loading: false, error: null }); @@ -92,7 +92,6 @@ describe("BurnScreen", () => { render(); openDialog("100"); - expect(mockBurn).not.toHaveBeenCalled(); confirmBurn(); await waitFor(() => { @@ -102,7 +101,7 @@ describe("BurnScreen", () => { await waitFor(() => { expect(screen.getByTestId("burn-success")).toHaveTextContent( - "Destroyed 100 tokens", + "Transaction hash: 0xburnhash", ); }); expect(screen.getByTestId("burn-success")).toHaveTextContent("0xburnhash"); @@ -209,4 +208,79 @@ describe("BurnScreen", () => { expect(screen.queryByTestId("burn-success")).not.toBeInTheDocument(); expect(screen.getByTestId("burn-total-supply")).toHaveTextContent("1000"); }); + + it("warns that burning is irreversible and styles the submit as danger", () => { + render(); + + expect(screen.getByTestId("burn-irreversible-warning")).toHaveTextContent( + /irreversible/i, + ); + expect(screen.getByTestId("burn-submit")).toHaveAttribute( + "data-variant", + "danger", + ); + }); + + it.each([ + ["0", "greater than zero"], + ["-5", "positive whole number"], + ["1.5", "positive whole number"], + ["abc", "positive whole number"], + ])("shows a validation message for %s", (value, message) => { + mockUseBalance.mockReturnValue({ data: 500n, loading: false, error: null }); + render(); + + fireEvent.change(screen.getByLabelText("Amount"), { target: { value } }); + + expect(screen.getByTestId("burn-amount-error")).toHaveTextContent(message); + expect(screen.getByTestId("burn-submit")).toBeDisabled(); + }); + + it("shows no validation message for an empty amount", () => { + render(); + expect(screen.queryByTestId("burn-amount-error")).not.toBeInTheDocument(); + }); + + it("MAX fills the full balance and requires confirmation before burning", async () => { + mockUseBalance.mockReturnValue({ data: 500n, loading: false, error: null }); + render(); + + fireEvent.click(screen.getByTestId("burn-max")); + + expect(screen.getByLabelText("Amount")).toHaveValue("500"); + expect(screen.getByTestId("burn-max-confirm-warning")).toBeInTheDocument(); + expect(screen.getByTestId("burn-submit")).toBeDisabled(); + + fireEvent.click(screen.getByLabelText(/burn my entire balance/i)); + expect(screen.getByTestId("burn-submit")).toBeEnabled(); + + fireEvent.click(screen.getByTestId("burn-submit")); + confirmBurn(); + await waitFor(() => expect(mockBurn).toHaveBeenCalledWith(ADDRESS, 500n)); + }); + + it("editing the amount after MAX drops the confirmation requirement", () => { + mockUseBalance.mockReturnValue({ data: 500n, loading: false, error: null }); + render(); + + fireEvent.click(screen.getByTestId("burn-max")); + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "100" }, + }); + + expect( + screen.queryByTestId("burn-max-confirm-warning"), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("burn-submit")).toBeEnabled(); + }); + + it("disables MAX when balance is unknown or zero", () => { + mockUseBalance.mockReturnValue({ data: 0n, loading: false, error: null }); + const { rerender } = render(); + expect(screen.getByTestId("burn-max")).toBeDisabled(); + + mockUseBalance.mockReturnValue({ data: null, loading: false, error: null }); + rerender(); + expect(screen.getByTestId("burn-max")).toBeDisabled(); + }); }); diff --git a/react/src/screens/BurnScreen.tsx b/react/src/screens/BurnScreen.tsx index 625ef74e..c3dfbd1e 100644 --- a/react/src/screens/BurnScreen.tsx +++ b/react/src/screens/BurnScreen.tsx @@ -8,7 +8,8 @@ import { TransactionHistory, useSessionTransactions, } from "../components/TransactionHistory"; -import { parseWalletError } from "../utils"; +import { isPositiveInteger, parseWalletError } from "../utils"; +import { theme } from "../theme"; export interface BurnScreenProps { /** Explicit connected wallet address (takes precedence over the adapter). */ @@ -25,9 +26,34 @@ export interface BurnScreenProps { className?: string; } +/** + * Returns a user-facing validation message for a burn amount, or `null` when + * the text is empty or acceptable. A balance of `null` skips the upper bound. + */ +export function getBurnAmountError( + text: string, + balance: bigint | null, +): string | null { + const trimmed = text.trim(); + if (trimmed === "") return null; + if (/^\d+$/.test(trimmed) && BigInt(trimmed) === 0n) { + return "Amount must be greater than zero."; + } + if (!isPositiveInteger(trimmed)) return "Enter a positive whole number."; + if (balance !== null && BigInt(trimmed) > balance) { + return "Amount exceeds the connected balance."; + } + return null; +} + /** * Burn screen: lets the connected wallet burn its own tokens. * + * Burning is irreversible, so the form uses danger styling and a warning. + * State: `amountText` (raw input), `maxPending` (set by MAX, cleared when the + * amount is edited), `maxConfirmed` (user acknowledged burning the whole + * balance). + * * Reads the connected address and balance via `useBalance` and submits via * `useBurn`, which signs with the connected wallet adapter configured on the * SDK client (no secret key field, no raw RPC transaction building here). @@ -79,6 +105,8 @@ export const BurnScreen: React.FC = ({ base: bigint | null; amount: bigint; } | null>(null); + const [maxPending, setMaxPending] = useState(false); + const [maxConfirmed, setMaxConfirmed] = useState(false); const amount = useMemo(() => { if (!/^\d+$/.test(amountText)) return null; @@ -89,17 +117,25 @@ export const BurnScreen: React.FC = ({ } }, [amountText]); - const isPositiveInteger = amount !== null && amount > 0n; - const overBalance = - isPositiveInteger && balance !== null ? amount > balance : false; + const isPositive = amount !== null && amount > 0n; + const overBalance = isPositive && balance !== null ? amount > balance : false; + const amountError = getBurnAmountError(amountText, balance); const canSubmit = isConnected && Boolean(connectedAddress) && - isPositiveInteger && + isPositive && + (!maxPending || maxConfirmed) && !overBalance && balance !== null && !burnLoading; + const handleMax = () => { + if (balance === null || balance <= 0n) return; + setAmountText(balance.toString()); + setMaxPending(true); + setMaxConfirmed(false); + }; + const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); if (!canSubmit) return; @@ -150,16 +186,37 @@ export const BurnScreen: React.FC = ({ return (
-

+

Burn Tokens

Burn tokens from the connected wallet.

+ + Burned tokens are destroyed permanently and cannot be recovered. +
Connected address
@@ -196,8 +253,31 @@ export const BurnScreen: React.FC = ({ inputMode="numeric" placeholder="Positive integer amount" value={amountText} - onChange={(event) => setAmountText(event.target.value)} + aria-invalid={amountError !== null} + onChange={(event) => { + setAmountText(event.target.value); + setMaxPending(false); + setMaxConfirmed(false); + }} /> + + + {amountError ? ( +
+ {amountError} +
+ ) : null} {overBalance ? ( @@ -205,6 +285,23 @@ export const BurnScreen: React.FC = ({ ) : null} + {maxPending ? ( + + + + ) : null} + {burnError ? ( {parseWalletError(burnError)} @@ -228,7 +325,21 @@ export const BurnScreen: React.FC = ({ ) : null} - diff --git a/react/src/screens/RolesScreen.test.tsx b/react/src/screens/RolesScreen.test.tsx index 98837e0f..2ae12a11 100644 --- a/react/src/screens/RolesScreen.test.tsx +++ b/react/src/screens/RolesScreen.test.tsx @@ -271,4 +271,42 @@ describe("RolesScreen", () => { fireEvent.click(screen.getByRole("button", { name: "Revoke" })); expect(await screen.findByText(/FAILHASH/)).toBeInTheDocument(); }); + + it("explains each role permission in a badge tooltip and distinguishes granted from not granted", async () => { + renderWithWallet(false); + + typeTarget(TARGET); + + await waitFor(() => { + expect(screen.getByTestId("role-badge-Minter")).toHaveTextContent( + "Minter: Granted", + ); + }); + + expect(screen.getByTestId("role-badge-Minter")).toHaveAttribute( + "title", + expect.stringMatching(/mint/i), + ); + expect(screen.getByTestId("role-badge-Pauser")).toHaveAttribute( + "title", + expect.stringMatching(/pause/i), + ); + expect(screen.getByTestId("role-badge-Admin")).toHaveAttribute( + "title", + expect.any(String), + ); + expect(screen.getByTestId("role-badge-SuperAdmin")).toHaveAttribute( + "title", + expect.stringMatching(/super/i), + ); + expect(screen.getByTestId("role-badge-Minter")).toHaveAttribute( + "data-status", + "granted", + ); + expect(screen.getByTestId("role-badge-Admin")).toHaveAttribute( + "data-status", + "not-granted", + ); + }); }); + diff --git a/react/src/screens/RolesScreen.tsx b/react/src/screens/RolesScreen.tsx index 4d8604c0..a233c2f2 100644 --- a/react/src/screens/RolesScreen.tsx +++ b/react/src/screens/RolesScreen.tsx @@ -50,6 +50,15 @@ const ROLE_INFO: Record = }, }; +/** Permission summary shown as the tooltip on each role badge (#1226). */ +const ROLE_PERMISSIONS: Record = { + [Role.Admin]: "Admin: configures the contract and manages roles.", + [Role.SuperAdmin]: + "SuperAdmin: highest authority; grants and revokes every role.", + [Role.Minter]: "Minter: can mint new tokens.", + [Role.Pauser]: "Pauser: can pause and unpause the contract in an emergency.", +}; + /** Stellar public key (G... strkey) shape check. */ const STELLAR_ADDRESS = /^G[A-Z2-7]{55}$/; @@ -314,6 +323,14 @@ export const RolesScreen: React.FC = ({
{role}:{" "}