From 3dfe380afc5c85bf7b4637e2931d49afd6b98612 Mon Sep 17 00:00:00 2001 From: 0xDeon Date: Sun, 18 Oct 2026 23:12:09 +0100 Subject: [PATCH] feat(react): transfer address validation, balance check, progress states and receipt modal Closes #1211, #1212, #1215, #1216 --- react/src/components/TransferReceiptModal.tsx | 231 +++++++++++++ react/src/components/index.ts | 20 +- react/src/screens/TransferScreen.test.tsx | 310 +++++++++++++++--- react/src/screens/TransferScreen.tsx | 293 ++++++++++++++--- 4 files changed, 757 insertions(+), 97 deletions(-) create mode 100644 react/src/components/TransferReceiptModal.tsx diff --git a/react/src/components/TransferReceiptModal.tsx b/react/src/components/TransferReceiptModal.tsx new file mode 100644 index 00000000..a22ab8b6 --- /dev/null +++ b/react/src/components/TransferReceiptModal.tsx @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MIT +import React, { useEffect, useId, useRef } from "react"; +import { createPortal } from "react-dom"; +import { theme } from "../theme"; +import { CopyToClipboard } from "./CopyToClipboard"; + +/** Details of a confirmed transfer rendered by {@link TransferReceiptModal}. */ +export interface TransferReceipt { + /** Address the tokens were sent from (the connected wallet). */ + from: string; + /** Address the tokens were sent to. */ + to: string; + /** Raw amount transferred, in the token's smallest unit. */ + amount: bigint; + /** Hash of the confirmed transaction. */ + hash: string; + /** Moment the transfer was confirmed. */ + timestamp: Date; +} + +export interface TransferReceiptModalProps { + /** Receipt to render. The modal is closed while this is `null`. */ + receipt: TransferReceipt | null; + /** Called by the Close button, the backdrop and `Escape`. */ + onClose: () => void; + /** + * Base URL of a block explorer transaction page, e.g. + * `'https://stellar.expert/explorer/testnet/tx'`. The hash is appended as a + * path segment. The explorer link is omitted when this is not set. + */ + explorerUrl?: string; + /** Formats the raw amount for display. Defaults to plain string rendering. */ + formatAmount?: (amount: bigint) => string; + /** Portal host; defaults to `document.body`. */ + portalContainer?: HTMLElement | null; +} + +const BACKDROP_STYLE: React.CSSProperties = { + position: "fixed", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 16, + backgroundColor: "rgba(17, 24, 39, 0.55)", + zIndex: 1000, + boxSizing: "border-box", +}; + +const PANEL_STYLE: React.CSSProperties = { + width: "100%", + maxWidth: 480, + display: "flex", + flexDirection: "column", + gap: 12, + padding: 20, + border: `1px solid ${theme.successBorder}`, + borderRadius: 12, + backgroundColor: theme.surface, + color: theme.text, + boxShadow: `0 20px 45px ${theme.shadow}`, + boxSizing: "border-box", +}; + +const ROW_LABEL_STYLE: React.CSSProperties = { + fontSize: 12, + fontWeight: 600, + color: theme.neutralText, +}; + +const ROW_VALUE_STYLE: React.CSSProperties = { + fontSize: 13, + fontFamily: "monospace", + wordBreak: "break-all", + margin: "2px 0 0 0", +}; + +const ACTION_STYLE: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + padding: "6px 10px", + fontSize: 13, + fontWeight: 500, + borderRadius: 6, + border: `1px solid ${theme.border}`, + color: theme.primaryText, + backgroundColor: theme.surface, + textDecoration: "none", +}; + +const CLOSE_STYLE: React.CSSProperties = { + padding: "9px 16px", + border: `1px solid ${theme.primary}`, + borderRadius: 6, + backgroundColor: theme.primary, + color: theme.surface, + fontSize: 14, + fontWeight: 500, + cursor: "pointer", +}; + +/** Builds the explorer URL for `hash`, or `undefined` when no base URL is set. */ +export function buildExplorerTxUrl( + explorerUrl: string | undefined, + hash: string, +): string | undefined { + if (!explorerUrl) return undefined; + return `${explorerUrl.replace(/\/$/, "")}/${encodeURIComponent(hash)}`; +} + +/** + * Receipt modal shown once a transfer is confirmed (#1216). + * + * Lists sender, recipient, amount, timestamp and transaction hash, with a + * "Copy Tx Hash" button and a "View on Explorer" link. Focus moves to the + * Close button on open; `Escape` and the backdrop dismiss it. + */ +export const TransferReceiptModal: React.FC = ({ + receipt, + onClose, + explorerUrl, + formatAmount, + portalContainer, +}) => { + const titleId = useId(); + const closeRef = useRef(null); + + useEffect(() => { + if (!receipt) return; + closeRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [receipt, onClose]); + + if (!receipt) return null; + const host = + portalContainer ?? (typeof document !== "undefined" ? document.body : null); + if (!host) return null; + + const href = buildExplorerTxUrl(explorerUrl, receipt.hash); + const amount = formatAmount + ? formatAmount(receipt.amount) + : receipt.amount.toString(); + const rows: Array<[string, string, string]> = [ + ["From", receipt.from, "transfer-receipt-from"], + ["To", receipt.to, "transfer-receipt-to"], + ["Amount", amount, "transfer-receipt-amount"], + [ + "Timestamp", + receipt.timestamp.toLocaleString(), + "transfer-receipt-timestamp", + ], + ["Transaction hash", receipt.hash, "transfer-receipt-hash"], + ]; + + return createPortal( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+

+ Transfer confirmed +

+
+ {rows.map(([label, value, testId]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+
+ + {href ? ( + + View on Explorer + + ) : null} +
+
+ +
+
+
, + host, + ); +}; diff --git a/react/src/components/index.ts b/react/src/components/index.ts index af8c6b28..2f2b519e 100644 --- a/react/src/components/index.ts +++ b/react/src/components/index.ts @@ -132,17 +132,6 @@ export type { ProposalId, } from "./ProposalVotingPanel"; -export { - TransactionHistory, - useSessionTransactions, -} from "./TransactionHistory"; -export type { - TransactionHistoryProps, - TransactionRecord, - TransactionOperation, - TransactionRecordStatus, -} from "./TransactionHistory"; - export { TransactionToast } from "./TransactionToast"; export type { TransactionToastProps, @@ -152,6 +141,15 @@ export type { export { CopyToClipboard } from "./CopyToClipboard"; export type { CopyToClipboardProps } from "./CopyToClipboard"; +export { + TransferReceiptModal, + buildExplorerTxUrl, +} from "./TransferReceiptModal"; +export type { + TransferReceipt, + TransferReceiptModalProps, +} from "./TransferReceiptModal"; + export { ConnectionStatusBar, getNetworkName, diff --git a/react/src/screens/TransferScreen.test.tsx b/react/src/screens/TransferScreen.test.tsx index b1c9381c..31c8520d 100644 --- a/react/src/screens/TransferScreen.test.tsx +++ b/react/src/screens/TransferScreen.test.tsx @@ -1,12 +1,44 @@ // SPDX-License-Identifier: MIT import "@testing-library/jest-dom"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { TransferScreen } from "./TransferScreen"; +import React from "react"; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from "@testing-library/react"; +import { + TransferScreen, + validateTransferDestination, + validateTransferAmount, + describeTransferError, + TRANSFER_SELF_ERROR, + TRANSFER_INVALID_ADDRESS_ERROR, + TRANSFER_INSUFFICIENT_BALANCE_ERROR, +} from "./TransferScreen"; const mockUseBalance = jest.fn(); +const mockRefetch = jest.fn(); const mockTransfer = jest.fn(); const mockEstimate = jest.fn(); -let mockFeeData: unknown = null; +let mockFeeData: { + feeStroops: bigint; + feeXlm: string; + recipientExists: boolean; +} | null = null; +const mockTransferState: { loading: boolean; error: Error | null } = { + loading: false, + error: null, +}; + +// The SDK entry point loads ESM-only @noble/* packages Jest cannot require, so +// expose only the real StrKey implementation, which has no such dependency. +jest.mock("@stellar/stellar-sdk", () => ({ + StrKey: jest.requireActual<{ StrKey: unknown }>( + "../../../node_modules/@stellar/stellar-sdk/lib/cjs/base/strkey.js", + ).StrKey, +})); jest.mock("../hooks", () => ({ useBalance: (...args: unknown[]) => mockUseBalance(...args), @@ -19,24 +51,76 @@ jest.mock("../hooks", () => ({ }), useTransfer: () => ({ transfer: mockTransfer, - loading: false, - error: null as Error | null, + loading: mockTransferState.loading, + error: mockTransferState.error, }), })); -const ADDRESS = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVCBCY42YOXT"; -const DESTINATION = "GB7B4Z57YDO7PZPO4GBDJJYTYKVOPZNWADPTO7MSKNRZWWTULCA2MB2I"; +const ADDRESS = "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR"; +const DESTINATION = "GCATS5YOVB6ROX2WUNKGNQ2MP3GMXDMKSG2O4N5CLX3A6W4PZGZZI55U"; + +function fillForm(destination: string, amount: string) { + fireEvent.change(screen.getByLabelText("Destination address"), { + target: { value: destination }, + }); + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: amount }, + }); +} + +describe("TransferScreen validation helpers", () => { + it("validates destination addresses", () => { + expect(validateTransferDestination("", ADDRESS)).toBeNull(); + expect(validateTransferDestination("not-a-key", ADDRESS)).toBe( + TRANSFER_INVALID_ADDRESS_ERROR, + ); + expect( + validateTransferDestination(`${DESTINATION.slice(0, -1)}A`, ADDRESS), + ).toBe(TRANSFER_INVALID_ADDRESS_ERROR); + expect(validateTransferDestination(` ${ADDRESS} `, ADDRESS)).toBe( + TRANSFER_SELF_ERROR, + ); + expect(validateTransferDestination(DESTINATION, ADDRESS)).toBeNull(); + expect(validateTransferDestination(DESTINATION, undefined)).toBeNull(); + }); + + it("validates amounts against the balance", () => { + expect(validateTransferAmount("", 10n)).toBeNull(); + expect(validateTransferAmount("1.5", 10n)).not.toBeNull(); + expect(validateTransferAmount("0", 10n)).not.toBeNull(); + expect(validateTransferAmount("11", 10n)).toBe( + TRANSFER_INSUFFICIENT_BALANCE_ERROR, + ); + expect(validateTransferAmount("10", 10n)).toBeNull(); + expect(validateTransferAmount("11", null)).toBeNull(); + }); + + it("describes common transfer failures", () => { + expect( + describeTransferError(new Error("User rejected the request")), + ).toMatch(/rejected/); + expect(describeTransferError(new Error("Request timed out"))).toMatch( + /timed out/, + ); + expect(describeTransferError(new Error("Network Error"))).toMatch( + /network error/, + ); + expect(describeTransferError(new Error("boom"))).toBe("boom"); + }); +}); describe("TransferScreen", () => { beforeEach(() => { jest.clearAllMocks(); - mockFeeData = null; - mockEstimate.mockResolvedValue({ - feeStroops: 100n, - feeXlm: "0.0000100", - recipientExists: true, + mockTransferState.loading = false; + mockTransferState.error = null; + mockRefetch.mockResolvedValue(undefined); + mockUseBalance.mockReturnValue({ + data: null, + loading: false, + error: null, + refetch: mockRefetch, }); - mockUseBalance.mockReturnValue({ data: null, loading: false, error: null }); mockTransfer.mockResolvedValue({ success: true, hash: "0xtransferhash" }); }); @@ -45,65 +129,217 @@ describe("TransferScreen", () => { data: 1_000n, loading: false, error: null, + refetch: mockRefetch, }); - // Disconnected: no address at all. const { rerender } = render(); expect(screen.getByTestId("transfer-connected-address")).toHaveTextContent( "Not connected", ); + expect(screen.getByTestId("transfer-disconnected")).toBeInTheDocument(); expect(screen.getByTestId("transfer-submit")).toBeDisabled(); - // Connected but destination empty and amount empty. rerender(); expect(screen.getByTestId("transfer-balance")).toHaveTextContent("1000"); expect(screen.getByTestId("transfer-submit")).toBeDisabled(); - // Non-integer / zero amount. - fireEvent.change(screen.getByLabelText("Destination address"), { - target: { value: DESTINATION }, - }); - fireEvent.change(screen.getByLabelText("Amount"), { - target: { value: "0" }, - }); + fillForm(DESTINATION, "0"); expect(screen.getByTestId("transfer-submit")).toBeDisabled(); - // Amount above the displayed balance. fireEvent.change(screen.getByLabelText("Amount"), { target: { value: "1001" }, }); expect(screen.getByTestId("transfer-submit")).toBeDisabled(); - expect(screen.getByTestId("transfer-over-balance")).toBeInTheDocument(); + expect(screen.getByTestId("transfer-over-balance")).toHaveTextContent( + "Insufficient balance", + ); expect(mockTransfer).not.toHaveBeenCalled(); }); - it("submits a transfer from the connected address and shows the transaction hash", async () => { + it("shows destination validation messages and blocks submission", () => { mockUseBalance.mockReturnValue({ data: 1_000n, loading: false, error: null, + refetch: mockRefetch, }); - render(); - fireEvent.change(screen.getByLabelText("Destination address"), { - target: { value: DESTINATION }, + fillForm("GABC", "10"); + expect(screen.getByTestId("transfer-destination-error")).toHaveTextContent( + "Invalid Stellar public key format", + ); + expect(screen.getByTestId("transfer-submit")).toBeDisabled(); + + fillForm(ADDRESS, "10"); + expect(screen.getByTestId("transfer-destination-error")).toHaveTextContent( + "Cannot transfer to self", + ); + expect(screen.getByTestId("transfer-submit")).toBeDisabled(); + + fillForm(DESTINATION, "10"); + expect( + screen.queryByTestId("transfer-destination-error"), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("transfer-submit")).toBeEnabled(); + }); + + it("submits once, clears the form, refetches the balance and opens the receipt", async () => { + mockUseBalance.mockReturnValue({ + data: 1_000n, + loading: false, + error: null, + refetch: mockRefetch, }); - fireEvent.change(screen.getByLabelText("Amount"), { - target: { value: "250" }, + let resolveTransfer: (value: { + success: boolean; + hash: string; + }) => void = () => {}; + mockTransfer.mockImplementation( + () => + new Promise((resolve) => { + resolveTransfer = resolve; + }), + ); + const onTransferConfirmed = jest.fn(); + + render( + , + ); + fillForm(DESTINATION, "250"); + + const submit = screen.getByTestId("transfer-submit"); + fireEvent.click(submit); + fireEvent.click(submit); + fireEvent.submit(submit.closest("form") as HTMLFormElement); + + await waitFor(() => + expect(screen.getByTestId("transfer-spinner")).toBeInTheDocument(), + ); + expect(submit).toBeDisabled(); + expect(submit).toHaveTextContent("Transferring…"); + expect(mockTransfer).toHaveBeenCalledTimes(1); + expect(mockTransfer).toHaveBeenCalledWith(ADDRESS, DESTINATION, 250n); + + await act(async () => { + resolveTransfer({ success: true, hash: "0xtransferhash" }); + }); + + const receipt = await screen.findByTestId("transfer-receipt"); + expect(receipt).toHaveAttribute("role", "dialog"); + expect(screen.getByTestId("transfer-receipt-from")).toHaveTextContent( + ADDRESS, + ); + expect(screen.getByTestId("transfer-receipt-to")).toHaveTextContent( + DESTINATION, + ); + expect(screen.getByTestId("transfer-receipt-amount")).toHaveTextContent( + "250", + ); + expect(screen.getByTestId("transfer-receipt-hash")).toHaveTextContent( + "0xtransferhash", + ); + expect( + screen.getByTestId("transfer-receipt-timestamp").textContent, + ).not.toBe(""); + expect(screen.getByText("Copy Tx Hash")).toBeInTheDocument(); + expect(screen.getByTestId("transfer-receipt-explorer")).toHaveAttribute( + "href", + "https://stellar.expert/explorer/testnet/tx/0xtransferhash", + ); + + expect(screen.getByLabelText("Destination address")).toHaveValue(""); + expect(screen.getByLabelText("Amount")).toHaveValue(""); + expect(mockRefetch).toHaveBeenCalledTimes(1); + expect(onTransferConfirmed).toHaveBeenCalledWith( + expect.objectContaining({ + from: ADDRESS, + to: DESTINATION, + amount: 250n, + hash: "0xtransferhash", + }), + ); + + fireEvent.click(screen.getByTestId("transfer-receipt-close")); + expect(screen.queryByTestId("transfer-receipt")).not.toBeInTheDocument(); + }); + + it("copies the transaction hash from the receipt", async () => { + mockUseBalance.mockReturnValue({ + data: 1_000n, + loading: false, + error: null, + refetch: mockRefetch, }); + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render( + , + ); + fillForm(DESTINATION, "5"); fireEvent.click(screen.getByTestId("transfer-submit")); - await waitFor(() => { - expect(mockTransfer).toHaveBeenCalledTimes(1); + await screen.findByTestId("transfer-receipt"); + expect(screen.getByTestId("transfer-receipt-explorer")).toHaveAttribute( + "href", + "https://explorer.test/tx/0xtransferhash", + ); + fireEvent.click(screen.getByTestId("copy-to-clipboard-button")); + await waitFor(() => + expect(writeText).toHaveBeenCalledWith("0xtransferhash"), + ); + }); + + it("keeps the form and shows a helpful alert when the transfer is rejected", async () => { + mockUseBalance.mockReturnValue({ + data: 1_000n, + loading: false, + error: null, + refetch: mockRefetch, }); - expect(mockTransfer).toHaveBeenCalledWith(ADDRESS, DESTINATION, 250n); + mockTransfer.mockRejectedValue(new Error("User rejected the request")); + + const { rerender } = render(); + fillForm(DESTINATION, "10"); + fireEvent.click(screen.getByTestId("transfer-submit")); + + await waitFor(() => expect(mockTransfer).toHaveBeenCalledTimes(1)); + mockTransferState.error = new Error("User rejected the request"); + rerender(); - await waitFor(() => { - expect(screen.getByTestId("transfer-success")).toHaveTextContent( - "Transaction hash: 0xtransferhash", - ); + await waitFor(() => + expect(screen.getByTestId("transfer-error")).toHaveTextContent( + "The transaction was rejected in your wallet.", + ), + ); + expect(screen.queryByTestId("transfer-receipt")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Destination address")).toHaveValue( + DESTINATION, + ); + expect(screen.getByLabelText("Amount")).toHaveValue("10"); + expect(mockRefetch).not.toHaveBeenCalled(); + expect(screen.getByTestId("transfer-submit")).toBeEnabled(); + }); + + it("shows a warning when the balance cannot be loaded", () => { + mockUseBalance.mockReturnValue({ + data: null, + loading: false, + error: new Error("Network Error"), + refetch: mockRefetch, }); + render(); + expect(screen.getByTestId("transfer-balance-error")).toHaveTextContent( + /network error/, + ); + expect(screen.getByTestId("transfer-submit")).toBeDisabled(); }); it("MAX fills the exact raw balance", () => { diff --git a/react/src/screens/TransferScreen.tsx b/react/src/screens/TransferScreen.tsx index ef83ed93..4dc2d0cc 100644 --- a/react/src/screens/TransferScreen.tsx +++ b/react/src/screens/TransferScreen.tsx @@ -1,7 +1,96 @@ // SPDX-License-Identifier: MIT -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { StrKey } from "@stellar/stellar-sdk"; import { useBalance, useEstimateFee, useTransfer } from "../hooks"; import { Alert } from "../components/Alert"; +import { + TransferReceiptModal, + type TransferReceipt, +} from "../components/TransferReceiptModal"; +import { parsePositiveInteger } from "../utils"; + +/** Validation message shown when the destination equals the connected address. */ +export const TRANSFER_SELF_ERROR = "Cannot transfer to self"; +/** Validation message shown when the destination is not a Stellar `G…` key. */ +export const TRANSFER_INVALID_ADDRESS_ERROR = + "Invalid Stellar public key format"; +/** Validation message shown when the amount is not a base-10 positive integer. */ +export const TRANSFER_INVALID_AMOUNT_ERROR = + "Amount must be a positive whole number"; +/** Validation message shown when the amount exceeds the live balance. */ +export const TRANSFER_INSUFFICIENT_BALANCE_ERROR = "Insufficient balance"; + +/** Default explorer used for the receipt's "View on Explorer" link. */ +export const DEFAULT_TRANSFER_EXPLORER_URL = + "https://stellar.expert/explorer/testnet/tx"; + +/** + * Validates a transfer destination (#1211). + * + * @param destination - Raw destination input; surrounding whitespace is ignored. + * @param connectedAddress - Connected account address, used to reject transfers to self. + * @returns An error message string, or `null` when valid. Empty input returns `null`. + */ +export function validateTransferDestination( + destination: string, + connectedAddress?: string, +): string | null { + const trimmed = destination.trim(); + if (trimmed.length === 0) return null; + if (!StrKey.isValidEd25519PublicKey(trimmed)) { + return TRANSFER_INVALID_ADDRESS_ERROR; + } + if (connectedAddress && trimmed === connectedAddress.trim()) { + return TRANSFER_SELF_ERROR; + } + return null; +} + +/** + * Validates a transfer amount (#1212). + * + * @param amountText - Raw amount input string. + * @param balance - Live account balance, or `null` when unknown. + * @returns An error message string, or `null` when valid. Empty input returns `null`. + */ +export function validateTransferAmount( + amountText: string, + balance: bigint | null, +): string | null { + const trimmed = amountText.trim(); + if (trimmed.length === 0) return null; + const parsed = parsePositiveInteger(trimmed); + if (parsed === null) { + return TRANSFER_INVALID_AMOUNT_ERROR; + } + if (balance !== null && parsed > balance) { + return TRANSFER_INSUFFICIENT_BALANCE_ERROR; + } + return null; +} + +/** + * Maps an unknown error or ContractError to a friendly user-facing message. + */ +export function describeTransferError(error: unknown): string { + if (error && typeof error === "object" && "message" in error) { + const message = String((error as { message: unknown }).message); + if (/rejected/i.test(message) || /denied/i.test(message)) { + return "The transaction was rejected in your wallet. No tokens were sent."; + } + if (/timeout|timed out/i.test(message)) { + return "The network timed out before the transfer was confirmed. Check your balance before retrying."; + } + if (/network/i.test(message)) { + return "A network error interrupted the transfer. Check your connection and try again."; + } + if (/exceeds balance/i.test(message) || /insufficient/i.test(message)) { + return "Transfer rejected: balance is insufficient."; + } + return message; + } + return "Transfer failed. Please check your network connection and retry."; +} export interface TransferScreenProps { /** Explicit connected wallet address (takes precedence over the adapter). */ @@ -10,6 +99,10 @@ export interface TransferScreenProps { adapterPublicKey?: string; /** Formats a bigint balance for display. Defaults to plain string rendering. */ formatBalance?: (balance: bigint) => string; + /** Base URL for transaction exploration; defaults to Stellar Expert testnet. */ + explorerUrl?: string; + /** Invoked when a transfer confirms on-chain (#1216). */ + onTransferConfirmed?: (receipt: TransferReceipt) => void; /** Optional container style. */ style?: React.CSSProperties; /** Optional container class. */ @@ -24,21 +117,31 @@ export interface TransferScreenProps { * `useTransfer`, which signs with the connected wallet adapter configured on * the SDK client (no secret key field, no Soroban transaction assembly here). * - * A MAX button fills the amount with the exact raw balance. Once the - * destination and amount are valid, `useEstimateFee` simulates the transfer to - * show the estimated XLM fee and whether the recipient account exists, before - * the wallet is asked to sign. + * State transitions: + * - `idle` → user edits the form; destination (#1211) and amount/balance + * (#1212) are validated on every keystroke and gate the submit button. + * - `submitting` → a synchronous ref guard blocks duplicate submissions while + * the button shows a spinner (#1215). + * - success → the form is cleared, the balance is refetched and the receipt + * modal opens (#1216). + * - failure → the form keeps its values and a danger alert explains the error. */ export const TransferScreen: React.FC = ({ walletAddress, adapterPublicKey, formatBalance, + explorerUrl = DEFAULT_TRANSFER_EXPLORER_URL, + onTransferConfirmed, style, className, }) => { const connectedAddress = walletAddress ?? adapterPublicKey; - const { data: balance, loading: balanceLoading } = - useBalance(connectedAddress); + const { + data: balance, + loading: balanceLoading, + error: balanceError, + refetch: refetchBalance, + } = useBalance(connectedAddress); const { transfer, loading: transferLoading, @@ -55,29 +158,32 @@ export const TransferScreen: React.FC = ({ const [destination, setDestination] = useState(""); const [amountText, setAmountText] = useState(""); - const [txHash, setTxHash] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [receipt, setReceipt] = useState(null); + const submittingRef = useRef(false); - const amount = useMemo(() => { - if (!/^\d+$/.test(amountText)) return null; - try { - return BigInt(amountText); - } catch { - return null; - } - }, [amountText]); + const destinationError = validateTransferDestination( + destination, + connectedAddress, + ); + const amountError = validateTransferAmount(amountText, balance); + const amount = parsePositiveInteger(amountText); + const pending = submitting || transferLoading; const isPositiveInteger = amount !== null && amount > 0n; const overBalance = isPositiveInteger && balance !== null ? amount > balance : false; const recipientMissing = feeEstimate?.recipientExists === false; + const canSubmit = Boolean(connectedAddress) && destination.trim().length > 0 && - isPositiveInteger && - !overBalance && + destinationError === null && + amount !== null && + amountError === null && !recipientMissing && balance !== null && - !transferLoading; + !pending; const trimmedDestination = destination.trim(); const canEstimate = @@ -92,9 +198,9 @@ export const TransferScreen: React.FC = ({ return; } // Errors are surfaced through `feeError`. - void estimate(connectedAddress, trimmedDestination, amount).catch( - () => undefined, - ); + void Promise.resolve( + estimate(connectedAddress, trimmedDestination, amount), + ).catch(() => undefined); }, [ canEstimate, connectedAddress, @@ -110,22 +216,42 @@ export const TransferScreen: React.FC = ({ const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - if (!connectedAddress || !canSubmit || amount === null) return; - setTxHash(null); + if ( + submittingRef.current || + !connectedAddress || + !canSubmit || + amount === null + ) + return; + submittingRef.current = true; + setSubmitting(true); + const to = destination.trim(); try { // No source Keypair is passed: the SDK client signs with the connected // wallet adapter, and the transfer moves from the connected address. - const result = await transfer( - connectedAddress, - destination.trim(), + const result = await transfer(connectedAddress, to, amount); + const confirmed: TransferReceipt = { + from: connectedAddress, + to, amount, - ); - setTxHash(result.hash); + hash: result.hash, + timestamp: new Date(), + }; + setDestination(""); + setAmountText(""); + setReceipt(confirmed); + onTransferConfirmed?.(confirmed); + void refetchBalance(); } catch { // The hook already surfaced the error via `transferError`. + } finally { + submittingRef.current = false; + setSubmitting(false); } }; + const closeReceipt = useCallback(() => setReceipt(null), []); + const format = (value: bigint) => formatBalance ? formatBalance(value) : value.toString(); @@ -162,7 +288,27 @@ export const TransferScreen: React.FC = ({ -
+ {!connectedAddress ? ( + + Connect a wallet to transfer tokens. + + ) : null} + + {connectedAddress && balanceError && balance === null ? ( + + {describeTransferError(balanceError)} + + ) : null} + + = ({ type="text" placeholder="G… destination address" value={destination} + disabled={pending} + aria-invalid={destinationError !== null} + aria-describedby={ + destinationError ? "transfer-destination-error" : undefined + } onChange={(event) => setDestination(event.target.value)} /> + {destinationError ? ( + + ) : null}
@@ -182,6 +343,9 @@ export const TransferScreen: React.FC = ({ inputMode="numeric" placeholder="Positive integer amount" value={amountText} + disabled={pending} + aria-invalid={amountError !== null} + aria-describedby={amountError ? "transfer-amount-error" : undefined} onChange={(event) => setAmountText(event.target.value)} style={{ flex: 1 }} /> @@ -194,6 +358,20 @@ export const TransferScreen: React.FC = ({ MAX
+ {amountError ? ( + + ) : null} {feeLoading ? (
Estimating fee…
@@ -233,40 +411,57 @@ export const TransferScreen: React.FC = ({ ) : null} - {overBalance ? ( - - Amount exceeds the displayed balance. - - ) : null} - - {transferError ? ( + {transferError && !pending ? ( - {transferError.message} - - ) : null} - - {txHash ? ( - - Transaction hash: {txHash} + {describeTransferError(transferError)} ) : null} + + ); };