Skip to content
Merged
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
88 changes: 81 additions & 7 deletions react/src/screens/BurnScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,18 @@ 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 },
});
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 });

Expand Down Expand Up @@ -92,7 +92,6 @@ describe("BurnScreen", () => {
render(<BurnScreen walletAddress={ADDRESS} />);

openDialog("100");
expect(mockBurn).not.toHaveBeenCalled();
confirmBurn();

await waitFor(() => {
Expand All @@ -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");
Expand Down Expand Up @@ -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(<BurnScreen walletAddress={ADDRESS} />);

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(<BurnScreen walletAddress={ADDRESS} />);

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(<BurnScreen walletAddress={ADDRESS} />);
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(<BurnScreen walletAddress={ADDRESS} />);

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(<BurnScreen walletAddress={ADDRESS} />);

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(<BurnScreen walletAddress={ADDRESS} />);
expect(screen.getByTestId("burn-max")).toBeDisabled();

mockUseBalance.mockReturnValue({ data: null, loading: false, error: null });
rerender(<BurnScreen walletAddress={ADDRESS} />);
expect(screen.getByTestId("burn-max")).toBeDisabled();
});
});
129 changes: 120 additions & 9 deletions react/src/screens/BurnScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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).
Expand Down Expand Up @@ -79,6 +105,8 @@ export const BurnScreen: React.FC<BurnScreenProps> = ({
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;
Expand All @@ -89,17 +117,25 @@ export const BurnScreen: React.FC<BurnScreenProps> = ({
}
}, [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;
Expand Down Expand Up @@ -150,16 +186,37 @@ export const BurnScreen: React.FC<BurnScreenProps> = ({
return (
<section
className={className}
style={{ display: "grid", gap: 12, ...style }}
style={{
display: "grid",
gap: 12,
border: `1px solid ${theme.dangerBorder}`,
borderRadius: 8,
padding: 16,
...style,
}}
>
<div>
<h1 style={{ margin: "0 0 8px 0", fontSize: "24px", fontWeight: 700 }}>
<h1
style={{
margin: "0 0 8px 0",
fontSize: "24px",
fontWeight: 700,
color: theme.dangerText,
}}
>
Burn Tokens
</h1>
<p style={{ margin: 0, color: "#4b5563", fontSize: "14px" }}>
Burn tokens from the connected wallet.
</p>
</div>
<Alert
variant="danger"
title="Irreversible action"
data-testid="burn-irreversible-warning"
>
Burned tokens are destroyed permanently and cannot be recovered.
</Alert>
<div>
<div>Connected address</div>
<div data-testid="burn-connected-address">
Expand Down Expand Up @@ -196,15 +253,55 @@ export const BurnScreen: React.FC<BurnScreenProps> = ({
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);
}}
/>
<button
type="button"
data-testid="burn-max"
disabled={balance === null || balance <= 0n}
onClick={handleMax}
>
MAX
</button>

{amountError ? (
<div
role="alert"
data-testid="burn-amount-error"
style={{ color: theme.dangerText }}
>
{amountError}
</div>
) : null}

{overBalance ? (
<Alert variant="warning" data-testid="burn-over-balance">
Amount exceeds the connected balance.
</Alert>
) : null}

{maxPending ? (
<Alert
variant="warning"
title="Burning entire balance"
data-testid="burn-max-confirm-warning"
>
<label>
<input
type="checkbox"
checked={maxConfirmed}
onChange={(event) => setMaxConfirmed(event.target.checked)}
/>{" "}
I understand this will burn my entire balance
</label>
</Alert>
) : null}

{burnError ? (
<Alert variant="danger" title="Burn failed" data-testid="burn-error">
{parseWalletError(burnError)}
Expand All @@ -228,7 +325,21 @@ export const BurnScreen: React.FC<BurnScreenProps> = ({
</Alert>
) : null}

<button type="submit" disabled={!canSubmit} data-testid="burn-submit">
<button
type="submit"
disabled={!canSubmit}
data-testid="burn-submit"
data-variant="danger"
style={{
padding: "8px 14px",
backgroundColor: canSubmit ? theme.danger : "#6b7280",
color: theme.surface,
border: "none",
borderRadius: 6,
fontWeight: 600,
cursor: canSubmit ? "pointer" : "not-allowed",
}}
>
{burnLoading ? "Burning…" : "Burn"}
</button>
</form>
Expand Down
38 changes: 38 additions & 0 deletions react/src/screens/RolesScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});

17 changes: 17 additions & 0 deletions react/src/screens/RolesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ const ROLE_INFO: Record<string, { description: string; permissions: string }> =
},
};

/** Permission summary shown as the tooltip on each role badge (#1226). */
const ROLE_PERMISSIONS: Record<string, string> = {
[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}$/;

Expand Down Expand Up @@ -314,6 +323,14 @@ export const RolesScreen: React.FC<RolesScreenProps> = ({
<div>
<Badge
data-testid={`role-badge-${role}`}
title={ROLE_PERMISSIONS[role]}
data-status={
!targetValid
? undefined
: roleState?.[role]
? "granted"
: "not-granted"
}
variant={roleState && roleState[role] ? "success" : "default"}
>
{role}:{" "}
Expand Down
Loading