diff --git a/.changeset/old-owls-tap.md b/.changeset/old-owls-tap.md new file mode 100644 index 000000000..0822bc3c7 --- /dev/null +++ b/.changeset/old-owls-tap.md @@ -0,0 +1,5 @@ +--- +"@onflow/kit": minor +--- + +Add `useCrossVmTransactionStatus` hook diff --git a/packages/kit/src/hooks/index.ts b/packages/kit/src/hooks/index.ts index 30033aa51..e7514cf4b 100644 --- a/packages/kit/src/hooks/index.ts +++ b/packages/kit/src/hooks/index.ts @@ -11,3 +11,4 @@ export {useCrossVmTokenBalance} from "./useCrossVmTokenBalance" export {useFlowTransactionStatus} from "./useFlowTransactionStatus" export {useCrossVmSpendNft} from "./useCrossVmSpendNft" export {useCrossVmSpendToken} from "./useCrossVmSpendToken" +export {useCrossVmTransactionStatus} from "./useCrossVmTransactionStatus" diff --git a/packages/kit/src/hooks/useCrossVmTransactionStatus.test.ts b/packages/kit/src/hooks/useCrossVmTransactionStatus.test.ts new file mode 100644 index 000000000..413221b24 --- /dev/null +++ b/packages/kit/src/hooks/useCrossVmTransactionStatus.test.ts @@ -0,0 +1,161 @@ +import {renderHook} from "@testing-library/react" +import {useFlowTransactionStatus} from "./useFlowTransactionStatus" +import {useCrossVmTransactionStatus} from "./useCrossVmTransactionStatus" +import {useFlowChainId} from "./useFlowChainId" +import {mock} from "node:test" + +jest.mock("./useFlowTransactionStatus") +jest.mock("./useFlowChainId") + +describe("useCrossVmTransactionStatus", () => { + test("should return transaction status from useFlowTransactionStatus", async () => { + const mockTxId = "0x123" + const mockStatus = { + status: "FINALIZED", + errorMessage: null, + } as any + jest.mocked(useFlowTransactionStatus).mockReturnValue({ + transactionStatus: mockStatus, + error: null, + }) + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + } as any) + const {result} = renderHook(() => + useCrossVmTransactionStatus({id: mockTxId}) + ) + expect(result.current.transactionStatus).toEqual(mockStatus) + expect(result.current.error).toBeNull() + expect(useFlowTransactionStatus).toHaveBeenCalledWith({ + id: mockTxId, + }) + }) + + test("should parse EVM events correctly, testnet", async () => { + const mockTxId = "0x123" + const mockStatus = { + status: "FINALIZED", + errorMessage: null, + events: [ + { + type: "A.8c5303eaa26202d6.EVM.TransactionExecuted", + data: { + hash: [ + parseInt("a", 16).toString(10), + parseInt("bc", 16).toString(10), + ], + errorCode: "0", + errorMessage: "", + }, + }, + ], + } as any + jest.mocked(useFlowTransactionStatus).mockReturnValue({ + transactionStatus: mockStatus, + error: null, + }) + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + } as any) + const {result} = renderHook(() => + useCrossVmTransactionStatus({id: mockTxId}) + ) + expect(result.current.transactionStatus).toEqual(mockStatus) + expect(result.current.evmResults).toEqual([ + { + status: "passed", + hash: "0x0abc", + }, + ]) + expect(result.current.error).toBeNull() + expect(useFlowTransactionStatus).toHaveBeenCalledWith({ + id: mockTxId, + }) + }) + + test("should parse EVM events correctly, mainnet", async () => { + const mockTxId = "0x123" + const mockStatus = { + status: "FINALIZED", + errorMessage: null, + events: [ + { + type: "A.e467b9dd11fa00df.EVM.TransactionExecuted", + data: { + hash: [ + parseInt("d4", 16).toString(10), + parseInt("f8", 16).toString(10), + ], + errorCode: "0", + errorMessage: "", + }, + }, + ], + } as any + jest.mocked(useFlowTransactionStatus).mockReturnValue({ + transactionStatus: mockStatus, + error: null, + }) + jest.mocked(useFlowChainId).mockReturnValue({ + data: "mainnet", + } as any) + const {result} = renderHook(() => + useCrossVmTransactionStatus({id: mockTxId}) + ) + expect(result.current.transactionStatus).toEqual(mockStatus) + expect(result.current.evmResults).toEqual([ + { + status: "passed", + hash: "0xd4f8", + }, + ]) + expect(result.current.error).toBeNull() + expect(useFlowTransactionStatus).toHaveBeenCalledWith({ + id: mockTxId, + }) + }) + + test("should handle error correctly", async () => { + const mockTxId = "0x123" + const mockError = new Error("Transaction not found") + jest.mocked(useFlowTransactionStatus).mockReturnValue({ + transactionStatus: null, + error: mockError, + }) + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + isLoading: false, + } as any) + const {result} = renderHook(() => + useCrossVmTransactionStatus({id: mockTxId}) + ) + expect(result.current.transactionStatus).toBeNull() + expect(result.current.error).toEqual(mockError) + expect(useFlowTransactionStatus).toHaveBeenCalledWith({ + id: mockTxId, + }) + }) + + test("should not poll transaction status if chain ID is unsupported and return error", async () => { + const mockTxId = "0x123" + jest.mocked(useFlowChainId).mockReturnValue({ + data: "unsupported", + isLoading: false, + isError: false, + error: null, + isSuccess: true, + } as any) + const {result} = renderHook(() => + useCrossVmTransactionStatus({id: mockTxId}) + ) + expect(result.current.transactionStatus).toBeNull() + expect(result.current.error).toEqual( + new Error( + "Unsupported chain: unsupported. Please ensure the chain ID is valid and supported." + ) + ) + expect(useFlowTransactionStatus).toHaveBeenCalledWith({ + id: undefined, + }) + }) +}) diff --git a/packages/kit/src/hooks/useCrossVmTransactionStatus.ts b/packages/kit/src/hooks/useCrossVmTransactionStatus.ts new file mode 100644 index 000000000..c8900ec08 --- /dev/null +++ b/packages/kit/src/hooks/useCrossVmTransactionStatus.ts @@ -0,0 +1,89 @@ +import * as fcl from "@onflow/fcl" +import {TransactionStatus} from "@onflow/typedefs" +import {CONTRACT_ADDRESSES} from "../constants" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowTransactionStatus} from "./useFlowTransactionStatus" + +export interface UseCrossVmTransactionStatusArgs { + /** The Flow transaction ID to monitor */ + id?: string +} + +export interface UseCrossVmTransactionStatusResult { + /** Latest transaction status, or null before any update */ + transactionStatus: TransactionStatus | null + /** EVM transaction results, if available */ + evmResults?: CallOutcome[] + /** Any error encountered during status updates */ + error: Error | null +} + +export interface CallOutcome { + status: "passed" | "failed" | "skipped" + hash?: string + errorMessage?: string +} + +export interface EvmTransactionExecutedData { + hash: string[] + index: string + type: string + payload: string[] + errorCode: string + errorMessage: string + gasConsumed: string + contractAddress: string + logs: string[] + blockHeight: string + returnedData: string[] + precompiledCalls: string[] + stateUpdateChecksum: string +} + +/** + * Subscribes to status updates for a given Cross-VM Flow transaction ID that executes EVM calls. + * This hook monitors the transaction status and extracts EVM call results if available. + * + * @returns {UseCrossVmTransactionStatusResult} + */ +export function useCrossVmTransactionStatus({ + id, +}: UseCrossVmTransactionStatusArgs): UseCrossVmTransactionStatusResult { + const chainId = useFlowChainId() + + const eventType = + chainId.data && chainId.data in CONTRACT_ADDRESSES + ? `A.${fcl.sansPrefix(CONTRACT_ADDRESSES[chainId.data as keyof typeof CONTRACT_ADDRESSES].EVM)}.EVM.TransactionExecuted` + : null + + const {transactionStatus, error} = useFlowTransactionStatus({ + id: eventType ? id : undefined, + }) + + if (eventType === null) { + return { + transactionStatus: null, + error: new Error( + `Unsupported chain: ${chainId.data}. Please ensure the chain ID is valid and supported.` + ), + } + } + + const evmEvents = transactionStatus?.events + ?.filter(event => event.type === eventType) + ?.map(event => event.data) as EvmTransactionExecutedData[] + + const evmResults: CallOutcome[] = evmEvents?.map(event => { + const {hash, errorCode, errorMessage} = event + const result: CallOutcome = { + status: errorCode === "0" ? "passed" : "failed", + hash: `0x${hash.map(h => parseInt(h, 10).toString(16).padStart(2, "0")).join("")}`, + } + if (event.errorMessage) { + result.errorMessage = errorMessage + } + return result + }) + + return {transactionStatus, error, evmResults: evmResults} +}