From ccbaff36e0ae576c53b5e9dc125be763f23c38cc Mon Sep 17 00:00:00 2001 From: Peolite001 Date: Sat, 20 Jun 2026 22:22:29 +0100 Subject: [PATCH] feat(ROADMAP-122): add unified transaction lifecycle state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the core lifecycle types and state machine hook for unifying transaction UX across all money flows. Purely additive — no existing files modified. - types/transaction.ts: TransactionState, TransactionContext, TransactionLifecycle (uses existing TransactionErrorDetails from transactionErrors.ts) - hooks/useTransactionLifecycle.ts: FSM with submitLock idempotency guard (uses existing mapTransactionError for error mapping) - __tests__/useTransactionLifecycle.test.ts: State machine coverage - __tests__/pollTransactionStatus.test.ts: Coverage for existing poll function The existing transactionErrors.ts is already centralized; this PR builds the lifecycle orchestration on top of it. Follow-up PRs will adopt this in: - useContractMutation / useConfirmedMutation hooks - TransactionStatus UI component - StepFinalSignature, LendPageClient, repay, send-remittance --- .../__tests__/useTransactionLifecycle.test.ts | 136 ++++++++++++++++++ src/app/hooks/useTransactionLifecycle.ts | 127 ++++++++++++++++ src/app/types/transaction.ts | 27 ++++ .../__tests__/pollTransactionStatus.test.ts | 98 +++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 src/app/hooks/__tests__/useTransactionLifecycle.test.ts create mode 100644 src/app/hooks/useTransactionLifecycle.ts create mode 100644 src/app/types/transaction.ts create mode 100644 src/app/utils/__tests__/pollTransactionStatus.test.ts diff --git a/src/app/hooks/__tests__/useTransactionLifecycle.test.ts b/src/app/hooks/__tests__/useTransactionLifecycle.test.ts new file mode 100644 index 0000000..d18c2d3 --- /dev/null +++ b/src/app/hooks/__tests__/useTransactionLifecycle.test.ts @@ -0,0 +1,136 @@ +import { renderHook, act } from "@testing-library/react"; +import { useTransactionLifecycle } from "../useTransactionLifecycle"; + +describe("useTransactionLifecycle", () => { + it("starts in idle state", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + expect(result.current.lifecycle.state).toBe("idle"); + expect(result.current.canSubmit).toBe(true); + expect(result.current.isProcessing).toBe(false); + }); + + it("transitions through full success flow", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + expect(result.current.lifecycle.state).toBe("building"); + + act(() => result.current.transition({ type: "BUILD_SUCCESS" })); + expect(result.current.lifecycle.state).toBe("awaiting-signature"); + + act(() => result.current.transition({ type: "SIGNATURE_SUCCESS" })); + expect(result.current.lifecycle.state).toBe("submitting"); + + act(() => + result.current.transition({ type: "SUBMIT_SUCCESS", txHash: "abc123" }), + ); + expect(result.current.lifecycle.state).toBe("confirming"); + expect(result.current.lifecycle.txHash).toBe("abc123"); + + act(() => result.current.transition({ type: "CONFIRM_SUCCESS" })); + expect(result.current.lifecycle.state).toBe("success"); + expect(result.current.isSuccess).toBe(true); + }); + + it("prevents double-submit via idempotency lock", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + act(() => result.current.transition({ type: "BUILD_SUCCESS" })); + act(() => result.current.transition({ type: "SIGNATURE_SUCCESS" })); + act(() => result.current.transition({ type: "SUBMIT" })); + + act(() => result.current.transition({ type: "SUBMIT" })); + expect(result.current.lifecycle.state).toBe("submitting"); + }); + + it("handles errors and allows retry", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + act(() => + result.current.transition({ + type: "ERROR", + error: new Error("User declined"), + }), + ); + + expect(result.current.lifecycle.state).toBe("error"); + expect(result.current.lifecycle.error?.category).toBe("wallet_rejected"); + expect(result.current.lifecycle.error?.retryable).toBe(true); + + act(() => result.current.transition({ type: "RETRY" })); + expect(result.current.lifecycle.state).toBe("building"); + expect(result.current.lifecycle.error).toBeNull(); + }); + + it("maps network errors correctly", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + act(() => + result.current.transition({ + type: "ERROR", + error: new Error("timeout"), + }), + ); + + expect(result.current.lifecycle.error?.category).toBe("network_timeout"); + expect(result.current.lifecycle.error?.retryable).toBe(true); + }); + + it("maps on-chain failures as non-retryable", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + act(() => + result.current.transition({ + type: "ERROR", + error: new Error("tx failed on-chain"), + }), + ); + + expect(result.current.lifecycle.error?.category).toBe("onchain_failure"); + expect(result.current.lifecycle.error?.retryable).toBe(false); + }); + + it("resets to idle", () => { + const { result } = renderHook(() => useTransactionLifecycle()); + + act(() => { + result.current.transition({ + type: "START_BUILD", + context: { operation: "test" }, + }); + }); + act(() => result.current.transition({ type: "RESET" })); + + expect(result.current.lifecycle.state).toBe("idle"); + expect(result.current.canSubmit).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/app/hooks/useTransactionLifecycle.ts b/src/app/hooks/useTransactionLifecycle.ts new file mode 100644 index 0000000..44f1aa0 --- /dev/null +++ b/src/app/hooks/useTransactionLifecycle.ts @@ -0,0 +1,127 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { TransactionContext, TransactionLifecycle } from "@/app/types/transaction"; +import { mapTransactionError } from "@/app/utils/transactionErrors"; + +type StateTransition = + | { type: "START_BUILD"; context: TransactionContext } + | { type: "BUILD_SUCCESS" } + | { type: "AWAIT_SIGNATURE" } + | { type: "SIGNATURE_SUCCESS" } + | { type: "SUBMIT" } + | { type: "SUBMIT_SUCCESS"; txHash: string } + | { type: "CONFIRM" } + | { type: "CONFIRM_SUCCESS" } + | { type: "ERROR"; error: unknown } + | { type: "RESET" } + | { type: "RETRY" }; + +const initialState: TransactionLifecycle = { + state: "idle", + context: { operation: "" }, + error: null, + txHash: null, + startedAt: null, + completedAt: null, +}; + +export function useTransactionLifecycle() { + const [lifecycle, setLifecycle] = useState(initialState); + const submitLock = useRef(false); + + const transition = useCallback((action: StateTransition) => { + setLifecycle((prev) => { + if (action.type === "SUBMIT" && submitLock.current) { + return prev; + } + + switch (action.type) { + case "START_BUILD": + submitLock.current = false; + return { + ...initialState, + state: "building", + context: action.context, + startedAt: Date.now(), + }; + + case "BUILD_SUCCESS": + if (prev.state !== "building") return prev; + return { ...prev, state: "awaiting-signature" }; + + case "AWAIT_SIGNATURE": + if (prev.state !== "building" && prev.state !== "idle") return prev; + return { ...prev, state: "awaiting-signature" }; + + case "SIGNATURE_SUCCESS": + if (prev.state !== "awaiting-signature") return prev; + return { ...prev, state: "submitting" }; + + case "SUBMIT": + if (prev.state !== "submitting" || submitLock.current) return prev; + submitLock.current = true; + return { ...prev, state: "submitting" }; + + case "SUBMIT_SUCCESS": + if (prev.state !== "submitting") return prev; + return { ...prev, state: "confirming", txHash: action.txHash }; + + case "CONFIRM": + if (prev.state !== "submitting" && prev.state !== "confirming") + return prev; + return { ...prev, state: "confirming" }; + + case "CONFIRM_SUCCESS": + submitLock.current = false; + return { + ...prev, + state: "success", + completedAt: Date.now(), + }; + + case "ERROR": + submitLock.current = false; + return { + ...prev, + state: "error", + error: mapTransactionError(action.error), + completedAt: Date.now(), + }; + + case "RETRY": + submitLock.current = false; + return { + ...prev, + state: "building", + error: null, + txHash: null, + completedAt: null, + }; + + case "RESET": + submitLock.current = false; + return initialState; + + default: + return prev; + } + }); + }, []); + + const canSubmit = lifecycle.state === "idle" || lifecycle.state === "error"; + const isProcessing = + lifecycle.state === "building" || + lifecycle.state === "awaiting-signature" || + lifecycle.state === "submitting" || + lifecycle.state === "confirming"; + + return { + lifecycle, + transition, + canSubmit, + isProcessing, + isSuccess: lifecycle.state === "success", + isError: lifecycle.state === "error", + }; +} \ No newline at end of file diff --git a/src/app/types/transaction.ts b/src/app/types/transaction.ts new file mode 100644 index 0000000..8ccc00f --- /dev/null +++ b/src/app/types/transaction.ts @@ -0,0 +1,27 @@ +import type { TransactionErrorDetails } from "@/app/utils/transactionErrors"; + +export type TransactionState = + | "idle" + | "building" + | "awaiting-signature" + | "submitting" + | "confirming" + | "success" + | "error"; + +export interface TransactionContext { + operation: string; + amount?: string; + asset?: string; + destination?: string; + metadata?: Record; +} + +export interface TransactionLifecycle { + state: TransactionState; + context: TransactionContext; + error: TransactionErrorDetails | null; + txHash: string | null; + startedAt: number | null; + completedAt: number | null; +} \ No newline at end of file diff --git a/src/app/utils/__tests__/pollTransactionStatus.test.ts b/src/app/utils/__tests__/pollTransactionStatus.test.ts new file mode 100644 index 0000000..13fe438 --- /dev/null +++ b/src/app/utils/__tests__/pollTransactionStatus.test.ts @@ -0,0 +1,98 @@ +import { pollTransactionStatus } from "../transactionErrors"; + +// Mock fetch globally +global.fetch = jest.fn(); + +describe("pollTransactionStatus", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("returns success when transaction is confirmed", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 404, + }) + .mockResolvedValueOnce({ + status: 404, + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ successful: true }), + }); + + const promise = pollTransactionStatus("tx123", { + horizonUrl: "https://horizon-testnet.stellar.org", + intervalMs: 100, + timeoutMs: 1000, + }); + + // Advance past two polling intervals + await jest.advanceTimersByTimeAsync(200); + + const result = await promise; + expect(result.status).toBe("success"); + expect(result.message).toBe("Transaction confirmed on-chain."); + }); + + it("returns failed when transaction fails on-chain", async () => { + (fetch as jest.Mock).mockResolvedValue({ + status: 200, + ok: true, + json: async () => ({ successful: false }), + }); + + const result = await pollTransactionStatus("tx456", { + horizonUrl: "https://horizon-testnet.stellar.org", + intervalMs: 100, + timeoutMs: 1000, + }); + + expect(result.status).toBe("failed"); + expect(result.message).toBe("Transaction failed on-chain."); + }); + + it("returns timeout when confirmation takes too long", async () => { + (fetch as jest.Mock).mockResolvedValue({ + status: 404, + }); + + const promise = pollTransactionStatus("tx789", { + horizonUrl: "https://horizon-testnet.stellar.org", + intervalMs: 100, + timeoutMs: 500, + }); + + await jest.advanceTimersByTimeAsync(600); + + const result = await promise; + expect(result.status).toBe("timeout"); + expect(result.message).toContain("still pending"); + }); + + it("returns cancelled when abort signal is triggered", async () => { + const controller = new AbortController(); + (fetch as jest.Mock).mockResolvedValue({ + status: 404, + }); + + const promise = pollTransactionStatus("tx000", { + horizonUrl: "https://horizon-testnet.stellar.org", + intervalMs: 100, + timeoutMs: 1000, + signal: controller.signal, + }); + + controller.abort(); + + const result = await promise; + expect(result.status).toBe("cancelled"); + expect(result.message).toBe("Status tracking cancelled by user."); + }); +}); \ No newline at end of file