diff --git a/fix.md b/fix.md
index 2ddff05..8a309e7 100644
--- a/fix.md
+++ b/fix.md
@@ -1,9 +1,9 @@
-[Frontend] Improve screen reader support for Multi-sig Approval Modal
+ [Frontend] Enable optimistic updates in Multi-sig Approval Modal
Repo Avatar
emdevelopa/Stellar_Payment_API
Description
This task involves UX enhancement for the Multi-sig Approval Modal module.
-The goal is to improve screen reader support for Multi-sig Approval Modal to improve the platform's styling, user interactions, and overall accessibility.
+The goal is to enable optimistic updates in Multi-sig Approval Modal to improve the platform's styling, user interactions, and overall accessibility.
Requirements and context
@@ -14,15 +14,15 @@ Specifically focused on frontend UX enhancement and responsiveness
Suggested execution
Fork the repo and create a branch
-git checkout -b feature/fe-improve-screen-reader-support-for-multi-sig-approval-modal
+git checkout -b feature/fe-enable-optimistic-updates-in-multi-sig-approval-modal
Implement changes
Review existing component in Multi-sig Approval Modal
-Apply changes: Improve screen reader support for Multi-sig Approval Modal
+Apply changes: Enable optimistic updates in Multi-sig Approval Modal
Use clean CSS or tailwind variables for styling
Maintain state transitions and visual feedback
Test and commit
Test mobile responsiveness and interactive states
Check accessibility (a11y) using standard audits
-Include screenshots or gifs in the PR
+Include screenshots or gifs in the PR
\ No newline at end of file
diff --git a/frontend/src/components/MultisigApprovalModal.test.tsx b/frontend/src/components/MultisigApprovalModal.test.tsx
index 45cb5da..bc79a69 100644
--- a/frontend/src/components/MultisigApprovalModal.test.tsx
+++ b/frontend/src/components/MultisigApprovalModal.test.tsx
@@ -281,6 +281,30 @@ describe("MultisigApprovalModal Component", () => {
});
describe("Confirmation State", () => {
+ it("shows optimistic pending state on submit before finalising", async () => {
+ renderWithProvider();
+
+ const signButtons = screen.getAllByText("Sign");
+ fireEvent.click(signButtons[0]);
+
+ await waitFor(() => {
+ expect(screen.getByText("50%")).toBeInTheDocument();
+ }, { timeout: 2000 });
+
+ fireEvent.click(signButtons[1]);
+
+ await waitFor(() => {
+ expect(screen.getByText("Submit Transaction")).toBeInTheDocument();
+ }, { timeout: 2000 });
+
+ fireEvent.click(screen.getByText("Submit Transaction"));
+
+ // Optimistic state appears immediately
+ expect(screen.getByText("Transaction Submitted")).toBeInTheDocument();
+ expect(screen.getByText("Awaiting network confirmation...")).toBeInTheDocument();
+ expect(screen.getByText("Confirming...")).toBeInTheDocument();
+ });
+
it("shows confirmation after successful submission", async () => {
renderWithProvider();
diff --git a/frontend/src/components/MultisigApprovalModal.tsx b/frontend/src/components/MultisigApprovalModal.tsx
index b696f68..6e6365e 100644
--- a/frontend/src/components/MultisigApprovalModal.tsx
+++ b/frontend/src/components/MultisigApprovalModal.tsx
@@ -80,6 +80,7 @@ export default function MultisigApprovalModal({
requiredSignatures,
progress,
isExpired,
+ isPendingConfirmation,
timeRemaining,
} = useMultisigState();
@@ -155,10 +156,10 @@ export default function MultisigApprovalModal({
}, [isOpen]);
const handleClose = useCallback(() => {
- if (isLoading) return;
+ if (isLoading || isPendingConfirmation) return;
resetModal();
onClose();
- }, [isLoading, resetModal, onClose]);
+ }, [isLoading, isPendingConfirmation, resetModal, onClose]);
const handleSign = useCallback(async (signerId: string) => {
try {
@@ -347,36 +348,72 @@ export default function MultisigApprovalModal({
const ConfirmStep = () => (
-
-
-
Transaction Approved
-
- Your multi-signature transaction has been successfully submitted
-
-
- {transaction?.submittedTxHash && (
-
-
Transaction Hash
-
-
- {transaction.submittedTxHash}
-
-
+ {isPendingConfirmation ? (
+ <>
+
-
+
+
Transaction Submitted
+
+ Awaiting network confirmation...
+
+
+ {transaction?.submittedTxHash && (
+
+
+ Transaction Hash (pending)
+
+
+
+ {transaction.submittedTxHash}
+
+
+
+
+ )}
+
+ Confirming...
+
+ >
+ ) : (
+ <>
+
+
+
Transaction Approved
+
+ Your multi-signature transaction has been successfully submitted
+
+
+ {transaction?.submittedTxHash && (
+
+
Transaction Hash
+
+
+ {transaction.submittedTxHash}
+
+
+
+
+ )}
+
+ Close
+
+ >
)}
-
- Close
-
);
diff --git a/frontend/src/lib/multisig-context.tsx b/frontend/src/lib/multisig-context.tsx
index b964160..886c000 100644
--- a/frontend/src/lib/multisig-context.tsx
+++ b/frontend/src/lib/multisig-context.tsx
@@ -45,6 +45,7 @@ export interface MultisigContextType {
transaction: MultisigTransaction | null;
currentStep: MultisigStep;
isLoading: boolean;
+ isPendingConfirmation: boolean;
error: string | null;
isMounted: boolean;
isVisible: boolean;
@@ -173,19 +174,26 @@ interface MultisigProviderProps {
readonly networkPassphrase: string;
}
-export function MultisigProvider({ children, networkPassphrase: _networkPassphrase }: MultisigProviderProps) {
- const [state, dispatch] = useReducer(multisigReducer, INITIAL_STATE);
-
- // Always-current snapshot for use inside async callbacks without stale-closure issues.
- const stateRef = useRef(state);
- stateRef.current = state;
+export function MultisigProvider({ children, networkPassphrase }: MultisigProviderProps) {
+ const [transaction, setTransaction] = useState
(null);
+ const [currentStep, setCurrentStep] = useState("review");
+ const [isLoading, setIsLoading] = useState(false);
+ const [isPendingConfirmation, setIsPendingConfirmation] = useState(false);
+ const [error, setError] = useState(null);
+ const [isMounted, setIsMounted] = useState(false);
+ const [isVisible, setIsVisible] = useState(false);
const clearError = useCallback(() => {
dispatch({ type: "CLEAR_ERROR" });
}, []);
const resetModal = useCallback(() => {
- dispatch({ type: "RESET" });
+ setTransaction(null);
+ setCurrentStep("review");
+ setIsLoading(false);
+ setIsPendingConfirmation(false);
+ setError(null);
+ setIsVisible(false);
}, []);
const setTransactionSafe = useCallback((newTransaction: MultisigTransaction | null) => {
@@ -256,27 +264,54 @@ export function MultisigProvider({ children, networkPassphrase: _networkPassphra
return;
}
- const signedWeight = transaction.signers
- .filter((s) => s.hasSigned)
- .reduce((sum, s) => sum + s.weight, 0);
- if (signedWeight < transaction.minSignatures) {
- dispatch({ type: "SET_ERROR", payload: "Not enough signatures to submit transaction" });
- return;
- }
+ const previousTransaction = { ...transaction, signers: [...transaction.signers] };
- dispatch({ type: "SET_LOADING", payload: true });
- dispatch({ type: "CLEAR_ERROR" });
- dispatch({ type: "SET_STEP", payload: "processing" });
+ try {
+ clearError();
+
+ // Verify enough signatures
+ const signedWeight = transaction.signers.filter(s => s.hasSigned)
+ .reduce((sum, s) => sum + s.weight, 0);
+
+ if (signedWeight < transaction.minSignatures) {
+ throw new Error("Not enough signatures to submit transaction");
+ }
+
+ // Optimistic update: immediately show confirm step with a pending tx hash
+ const pendingTxHash = `tx_pending_${Date.now()}`;
+ setIsPendingConfirmation(true);
+ setCurrentStep("confirm");
+ setTransactionSafe({
+ ...transaction,
+ status: 'approved' as MultisigApprovalStatus,
+ submittedTxHash: pendingTxHash,
+ });
+
+ // Simulate submission process
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // Finalize with real transaction hash
+ const realTxHash = `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ setTransactionSafe({
+ ...transaction,
+ status: 'approved' as MultisigApprovalStatus,
+ submittedTxHash: realTxHash,
+ });
try {
await new Promise((resolve) => setTimeout(resolve, 2000));
const txHash = `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
dispatch({ type: "SUBMIT_SUCCESS", txHash });
} catch (err) {
+ // Revert optimistic update
+ setTransactionSafe(previousTransaction);
const errorMessage = err instanceof Error ? err.message : "Failed to submit transaction";
dispatch({ type: "SET_ERROR", payload: errorMessage });
dispatch({ type: "SET_STEP", payload: "error" });
console.error("Submission error:", err);
+ } finally {
+ setIsLoading(false);
+ setIsPendingConfirmation(false);
}
}, []);
@@ -332,38 +367,44 @@ export function MultisigProvider({ children, networkPassphrase: _networkPassphra
};
}, [transaction, currentStep]);
- const value: MultisigContextType = useMemo(
- () => ({
- transaction,
- currentStep,
- isLoading,
- error,
- isMounted,
- isVisible,
- setTransaction: setTransactionSafe,
- setCurrentStep: (step: MultisigStep) => dispatch({ type: "SET_STEP", payload: step }),
- signTransaction,
- submitTransaction,
- resetModal,
- clearError,
- retryAction,
- ...computedValues,
- }),
- [
- transaction,
- currentStep,
- isLoading,
- error,
- isMounted,
- isVisible,
- setTransactionSafe,
- signTransaction,
- submitTransaction,
- resetModal,
- clearError,
- retryAction,
- computedValues,
- ],
+ const value: MultisigContextType = useMemo(() => ({
+ transaction,
+ currentStep,
+ isLoading,
+ isPendingConfirmation,
+ error,
+ isMounted,
+ isVisible,
+ setTransaction: setTransactionSafe,
+ setCurrentStep,
+ signTransaction,
+ submitTransaction,
+ resetModal,
+ clearError,
+ retryAction,
+ ...computedValues
+ }), [
+ transaction,
+ currentStep,
+ isLoading,
+ isPendingConfirmation,
+ error,
+ isMounted,
+ isVisible,
+ setTransactionSafe,
+ setCurrentStep,
+ signTransaction,
+ submitTransaction,
+ resetModal,
+ clearError,
+ retryAction,
+ computedValues
+ ]);
+
+ return (
+
+ {children}
+
);
return {children};
@@ -380,12 +421,13 @@ export function useMultisig() {
}
export function useMultisigState() {
- const {
- transaction,
- currentStep,
- isLoading,
- error,
- isMounted,
+ const {
+ transaction,
+ currentStep,
+ isLoading,
+ isPendingConfirmation,
+ error,
+ isMounted,
isVisible,
canSign,
canSubmit,
@@ -400,6 +442,7 @@ export function useMultisigState() {
transaction,
currentStep,
isLoading,
+ isPendingConfirmation,
error,
isMounted,
isVisible,
diff --git a/frontend/src/lib/multisig-optimistic.test.tsx b/frontend/src/lib/multisig-optimistic.test.tsx
index d9b2b91..7c4ffd3 100644
--- a/frontend/src/lib/multisig-optimistic.test.tsx
+++ b/frontend/src/lib/multisig-optimistic.test.tsx
@@ -29,22 +29,29 @@ const tx: MultisigTransaction = {
status: "pending",
};
+function cannedTx(overrides: Partial = {}): MultisigTransaction {
+ return { ...tx, ...overrides };
+}
+
function Consumer() {
- const { transaction, signedCount, currentStep, error } = useMultisigState();
- const { setTransaction, signTransaction } = useMultisigActions();
+ const { transaction, signedCount, currentStep, isPendingConfirmation, error } = useMultisigState();
+ const { setTransaction, signTransaction, submitTransaction } = useMultisigActions();
const s1 = transaction?.signers.find((s) => s.id === "s1");
const s2 = transaction?.signers.find((s) => s.id === "s2");
return (
{signedCount}
{currentStep}
+ {String(isPendingConfirmation)}
{error || "no-error"}
{String(!!s1?.hasSigned)}
{String(!!s1?.signature)}
{String(!!s2?.hasSigned)}
+ {transaction?.submittedTxHash || "none"}
+
);
}
@@ -102,4 +109,115 @@ describe("Multi-sig optimistic updates (#797)", () => {
fireEvent.click(screen.getByText("sign-s1"));
await waitFor(() => expect(screen.getByTestId("error")).not.toHaveTextContent("no-error"));
});
+
+ describe("Optimistic submission", () => {
+ it("immediately advances to confirm step on submit", async () => {
+ renderModal();
+ fireEvent.click(screen.getByText("set-tx"));
+ await waitFor(() => expect(screen.getByTestId("signed-count")).toHaveTextContent("0"));
+
+ // Sign both signers to meet threshold
+ fireEvent.click(screen.getByText("sign-s1"));
+ await waitFor(
+ () => expect(screen.getByTestId("s1-has-signature")).toHaveTextContent("true"),
+ { timeout: 2000 },
+ );
+
+ fireEvent.click(screen.getByText("sign-s2"));
+ await waitFor(
+ () => expect(screen.getByTestId("s2-signed")).toHaveTextContent("true"),
+ { timeout: 2000 },
+ );
+
+ // Submit optimistically — should skip "processing" and land on "confirm" immediately
+ fireEvent.click(screen.getByText("submit-tx"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("step")).toHaveTextContent("confirm");
+ });
+ });
+
+ it("sets isPendingConfirmation true on submit and false after settlement", async () => {
+ renderModal();
+ fireEvent.click(screen.getByText("set-tx"));
+ await waitFor(() => expect(screen.getByTestId("signed-count")).toHaveTextContent("0"));
+
+ fireEvent.click(screen.getByText("sign-s1"));
+ await waitFor(
+ () => expect(screen.getByTestId("s1-has-signature")).toHaveTextContent("true"),
+ { timeout: 2000 },
+ );
+
+ fireEvent.click(screen.getByText("sign-s2"));
+ await waitFor(
+ () => expect(screen.getByTestId("s2-signed")).toHaveTextContent("true"),
+ { timeout: 2000 },
+ );
+
+ fireEvent.click(screen.getByText("submit-tx"));
+
+ // Optimistic: pending confirmation is true immediately
+ await waitFor(() => {
+ expect(screen.getByTestId("pending-confirmation")).toHaveTextContent("true");
+ });
+
+ // Settled: pending confirmation flips to false
+ await waitFor(
+ () => expect(screen.getByTestId("pending-confirmation")).toHaveTextContent("false"),
+ { timeout: 3000 },
+ );
+ });
+
+ it("shows a pending tx hash during optimistic phase and real hash after settlement", async () => {
+ renderModal();
+ fireEvent.click(screen.getByText("set-tx"));
+ await waitFor(() => expect(screen.getByTestId("signed-count")).toHaveTextContent("0"));
+
+ fireEvent.click(screen.getByText("sign-s1"));
+ await waitFor(
+ () => expect(screen.getByTestId("s1-has-signature")).toHaveTextContent("true"),
+ { timeout: 2000 },
+ );
+
+ fireEvent.click(screen.getByText("sign-s2"));
+ await waitFor(
+ () => expect(screen.getByTestId("s2-signed")).toHaveTextContent("true"),
+ { timeout: 2000 },
+ );
+
+ fireEvent.click(screen.getByText("submit-tx"));
+
+ // Optimistic: a pending tx hash is set
+ await waitFor(() => {
+ expect(screen.getByTestId("tx-hash")).toMatch(/^tx_pending_/);
+ });
+
+ // Settled: a real tx hash replaces the pending one
+ await waitFor(
+ () => expect(screen.getByTestId("tx-hash")).toMatch(/^tx_(?!pending_)/),
+ { timeout: 3000 },
+ );
+ });
+
+ it("rolls back to error step when submission fails", async () => {
+ // Force a failure by not setting a transaction before calling submit
+ renderModal();
+
+ fireEvent.click(screen.getByText("set-tx"));
+ await waitFor(() => expect(screen.getByTestId("signed-count")).toHaveTextContent("0"));
+
+ fireEvent.click(screen.getByText("sign-s1"));
+ await waitFor(
+ () => expect(screen.getByTestId("s1-has-signature")).toHaveTextContent("true"),
+ { timeout: 2000 },
+ );
+
+ // Submit without enough signatures — should fail
+ fireEvent.click(screen.getByText("submit-tx"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error")).not.toHaveTextContent("no-error");
+ });
+ });
+ });
});