From 4afcbd0f1ff2505623a578a22aadc68b06379a88 Mon Sep 17 00:00:00 2001 From: mfbz Date: Thu, 18 Sep 2025 18:29:23 +0200 Subject: [PATCH 01/19] First implementation still preview --- packages/react-sdk/src/hooks/index.ts | 1 + .../src/hooks/useFlowScheduleManager.ts | 442 ++++++++++++++++++ 2 files changed, 443 insertions(+) create mode 100644 packages/react-sdk/src/hooks/useFlowScheduleManager.ts diff --git a/packages/react-sdk/src/hooks/index.ts b/packages/react-sdk/src/hooks/index.ts index 7c0b9b8d3..92724034c 100644 --- a/packages/react-sdk/src/hooks/index.ts +++ b/packages/react-sdk/src/hooks/index.ts @@ -15,3 +15,4 @@ export {useFlowTransactionStatus} from "./useFlowTransactionStatus" export {useCrossVmSpendNft} from "./useCrossVmSpendNft" export {useCrossVmSpendToken} from "./useCrossVmSpendToken" export {useCrossVmTransactionStatus} from "./useCrossVmTransactionStatus" +export {useFlowScheduleManager} from "./useFlowScheduleManager" diff --git a/packages/react-sdk/src/hooks/useFlowScheduleManager.ts b/packages/react-sdk/src/hooks/useFlowScheduleManager.ts new file mode 100644 index 000000000..408f0d02f --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowScheduleManager.ts @@ -0,0 +1,442 @@ +import {arg, t} from "@onflow/fcl" +import {UseQueryOptions} from "@tanstack/react-query" +import {useCallback, useMemo} from "react" +import {useFlowQueryClient} from "../provider/FlowQueryClient" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowClient} from "./useFlowClient" +import {useFlowMutate} from "./useFlowMutate" +import {useFlowQuery} from "./useFlowQuery" + +export enum Priority { + High = 0, + Medium = 1, + Low = 2, +} + +export enum Status { + Unknown = 0, + Scheduled = 1, + Executed = 2, + Canceled = 3, +} + +export interface TransactionData { + id: string + priority: Priority + executionEffort: string + status: Status + fees: string + scheduledTimestamp: string + handlerTypeIdentifier: string + handlerAddress: string + // Optional handler data when includeTransactionHandler is true + handler?: HandlerData +} + +export interface HandlerData { + typeIdentifier: string + // Not yet a standard so we need to let the client decide the type + metadata: {[viewType: string]: any} + // Optional transactions when includeHandlerTransactions is true + transactions?: TransactionData[] +} + +export interface HandlerCollection { + [typeIdentifier: string]: HandlerData +} + +export interface ScheduleManagerFilter { + // Include transactions for each handler + handlerTransactions?: boolean + // Include handler for each transaction + transactionHandler?: boolean +} + +export interface UseFlowScheduleManagerArgs { + // Manager address + address?: string + // Filter options for including related data + include?: ScheduleManagerFilter + // React Query settings + query?: Omit, "queryKey" | "queryFn"> + flowClient?: ReturnType +} + +export interface UseFlowScheduleManagerData { + transactions: TransactionData[] + handlers: HandlerCollection +} + +export interface UseFlowScheduleManagerResult { + data: UseFlowScheduleManagerData | null + isLoading: boolean + isFetching: boolean + error: Error | null + refetch: () => void + + // Query scripts + scripts: { + getManagerTransactions: ( + address: string, + includeHandler?: boolean + ) => Promise + getManagerHandlers: ( + address: string, + includeTransactions?: boolean + ) => Promise + getHandlerTransactions: ( + address: string, + handlerTypeIdentifier: string + ) => Promise + getTransactionHandler: ( + address: string, + transactionId: string + ) => Promise + } + + // Transaction mutations (will trigger refetch of data) + transactions: { + cancel: (address: string, transactionId: string) => Promise + cleanup: (address: string) => Promise + } +} + +// Cadence scripts with placeholders +const getManagerTransactionsInternal = ( + network: "testnet" | "mainnet" | "local", + includeHandler: boolean +) => ` +// TODO +` + +const getManagerHandlersInternal = ( + network: "testnet" | "mainnet" | "local", + includeTransactions: boolean +) => ` +// TODO +` + +const getHandlerTransactionsInternal = ( + network: "testnet" | "mainnet" | "local" +) => ` +// TODO +` + +const getTransactionHandlerInternal = ( + network: "testnet" | "mainnet" | "local" +) => ` +// TODO +` + +const cancelTransaction = (network: "testnet" | "mainnet" | "local") => ` +// TODO +` + +const cleanupManager = (network: "testnet" | "mainnet" | "local") => ` +// TODO +` + +/** + * Fetches and manages scheduled transactions for a Flow account + * + * @param args.address - The manager address to fetch data for + * @param args.include - Filter options for including related data + * @param args.query - Optional React Query options + * @param args.flowClient - Optional custom flow client + * + * @example + * // Auto-fetch data for an address + * const manager = useFlowScheduleManager({ + * address: "0x123...", + * include: { handlerTransactions: true } + * }) + * + * // Use without auto-fetching + * const manager = useFlowScheduleManager() + */ +export function useFlowScheduleManager({ + address, + include = {}, + query: queryOptions = {}, + flowClient, +}: UseFlowScheduleManagerArgs = {}): UseFlowScheduleManagerResult { + const chainIdResult = useFlowChainId() + const fcl = useFlowClient({flowClient}) + const queryClient = useFlowQueryClient() + + const network = chainIdResult.data as + | "testnet" + | "mainnet" + | "local" + | undefined + + // Internal query functions that can be reused + const queryAllTransactions = useCallback( + async ( + targetAddress: string, + includeHandler: boolean = false + ): Promise => { + if (!network) throw new Error("Network not detected") + if (!targetAddress) throw new Error("Address is required") + + try { + const result = await fcl.query({ + cadence: getManagerTransactionsInternal(network, includeHandler), + args: () => [arg(targetAddress, t.Address)], + }) + return (result as TransactionData[]) || [] + } catch (error) { + console.error("Failed to fetch transactions:", error) + throw error + } + }, + [network, fcl] + ) + + const queryAllHandlers = useCallback( + async ( + targetAddress: string, + includeTransactions: boolean = false + ): Promise => { + if (!network) throw new Error("Network not detected") + if (!targetAddress) throw new Error("Address is required") + + try { + const result = await fcl.query({ + cadence: getManagerHandlersInternal(network, includeTransactions), + args: () => [arg(targetAddress, t.Address)], + }) + return (result as HandlerCollection) || {} + } catch (error) { + console.error("Failed to fetch handlers:", error) + throw error + } + }, + [network, fcl] + ) + + // Auto-fetch transactions using the internal query function + const transactionsResult = useFlowQuery({ + cadence: + network && address + ? getManagerTransactionsInternal( + network, + include.transactionHandler || false + ) + : "", + args: () => (address ? [arg(address, t.Address)] : []), + query: { + ...queryOptions, + enabled: (queryOptions.enabled ?? true) && !!network && !!address, + }, + flowClient: fcl, + }) + + // Auto-fetch handlers using the internal query function + const handlersResult = useFlowQuery({ + cadence: + network && address + ? getManagerHandlersInternal( + network, + include.handlerTransactions || false + ) + : "", + args: () => (address ? [arg(address, t.Address)] : []), + query: { + ...queryOptions, + enabled: (queryOptions.enabled ?? true) && !!network && !!address, + }, + flowClient: fcl, + }) + + // Memoized data + const data = useMemo(() => { + if (!transactionsResult.data && !handlersResult.data) { + return null + } + + return { + transactions: (transactionsResult.data as TransactionData[]) || [], + handlers: (handlersResult.data as HandlerCollection) || {}, + } + }, [transactionsResult.data, handlersResult.data]) + + // Refetch function + const refetch = useCallback(async () => { + await Promise.all([transactionsResult.refetch(), handlersResult.refetch()]) + }, [transactionsResult, handlersResult]) + + // Exposed script methods that reuse the internal query functions + const getManagerTransactions = useCallback( + async ( + address: string, + includeHandler?: boolean + ): Promise => { + return queryAllTransactions(address, includeHandler || false) + }, + [queryAllTransactions] + ) + + const getManagerHandlers = useCallback( + async ( + address: string, + includeTransactions?: boolean + ): Promise => { + return queryAllHandlers(address, includeTransactions || false) + }, + [queryAllHandlers] + ) + + const getHandlerTransactions = useCallback( + async ( + address: string, + handlerTypeIdentifier: string + ): Promise => { + if (!network) throw new Error("Network not detected") + if (!address) throw new Error("Address is required") + if (!handlerTypeIdentifier) + throw new Error("Handler type identifier is required") + + try { + const result = await fcl.query({ + cadence: getHandlerTransactionsInternal(network), + args: () => [ + arg(address, t.Address), + arg(handlerTypeIdentifier, t.String), + ], + }) + return (result as TransactionData[]) || [] + } catch (error) { + console.error("Failed to fetch handler transactions:", error) + throw error + } + }, + [network, fcl] + ) + + const getTransactionHandler = useCallback( + async ( + address: string, + transactionId: string + ): Promise => { + if (!network) throw new Error("Network not detected") + if (!address) throw new Error("Address is required") + if (!transactionId) throw new Error("Transaction ID is required") + + try { + const result = await fcl.query({ + cadence: getTransactionHandlerInternal(network), + args: () => [arg(address, t.Address), arg(transactionId, t.UInt64)], + }) + return result as HandlerData | null + } catch (error) { + console.error("Failed to fetch transaction handler:", error) + throw error + } + }, + [network, fcl] + ) + + // Mutations + const cancelTransactionMutation = useFlowMutate({flowClient: fcl}) + const cleanupMutation = useFlowMutate({flowClient: fcl}) + + // Invalidate queries helper + const invalidateQueries = useCallback(() => { + if (address && network) { + queryClient.invalidateQueries({ + queryKey: [ + "flowQuery", + getManagerTransactionsInternal( + network, + include.transactionHandler || false + ), + ], + }) + queryClient.invalidateQueries({ + queryKey: [ + "flowQuery", + getManagerHandlersInternal( + network, + include.handlerTransactions || false + ), + ], + }) + } + }, [ + address, + network, + include.transactionHandler, + include.handlerTransactions, + queryClient, + ]) + + const cancel = useCallback( + async (targetAddress: string, transactionId: string): Promise => { + if (!network) throw new Error("Network not detected") + if (!targetAddress) throw new Error("Target address is required") + if (!transactionId) throw new Error("Transaction ID is required") + + try { + const result = await cancelTransactionMutation.mutateAsync({ + cadence: cancelTransaction(network), + args: () => [arg(transactionId, t.UInt64)], + }) + + // Refetch data if the cancel was for the currently watched address + if (targetAddress === address) { + await invalidateQueries() + } + + return result + } catch (error) { + console.error("Failed to cancel transaction:", error) + throw error + } + }, + [network, cancelTransactionMutation, address, invalidateQueries] + ) + + const cleanup = useCallback( + async (targetAddress: string): Promise => { + if (!network) throw new Error("Network not detected") + if (!targetAddress) throw new Error("Target address is required") + + try { + const result = await cleanupMutation.mutateAsync({ + cadence: cleanupManager(network), + args: () => [], + }) + + // Refetch data if the cleanup was for the currently watched address + if (targetAddress === address) { + await invalidateQueries() + } + + return result + } catch (error) { + console.error("Failed to cleanup manager:", error) + throw error + } + }, + [network, cleanupMutation, address, invalidateQueries] + ) + + return { + data, + isLoading: transactionsResult.isLoading || handlersResult.isLoading, + isFetching: transactionsResult.isFetching || handlersResult.isFetching, + error: transactionsResult.error || handlersResult.error, + refetch, + scripts: { + getManagerTransactions, + getManagerHandlers, + getHandlerTransactions, + getTransactionHandler, + }, + transactions: { + cancel, + cleanup, + }, + } +} From f1e376bb99cc7e41aa6c8ded1ce9e9ef68423720 Mon Sep 17 00:00:00 2001 From: mfbz Date: Thu, 18 Sep 2025 21:15:08 +0200 Subject: [PATCH 02/19] Minor fixes --- packages/react-sdk/src/hooks/useFlowScheduleManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-sdk/src/hooks/useFlowScheduleManager.ts b/packages/react-sdk/src/hooks/useFlowScheduleManager.ts index 408f0d02f..43290cd71 100644 --- a/packages/react-sdk/src/hooks/useFlowScheduleManager.ts +++ b/packages/react-sdk/src/hooks/useFlowScheduleManager.ts @@ -7,13 +7,13 @@ import {useFlowClient} from "./useFlowClient" import {useFlowMutate} from "./useFlowMutate" import {useFlowQuery} from "./useFlowQuery" -export enum Priority { +export enum TransactionPriority { High = 0, Medium = 1, Low = 2, } -export enum Status { +export enum TransactionStatus { Unknown = 0, Scheduled = 1, Executed = 2, @@ -22,9 +22,9 @@ export enum Status { export interface TransactionData { id: string - priority: Priority + priority: TransactionPriority executionEffort: string - status: Status + status: TransactionStatus fees: string scheduledTimestamp: string handlerTypeIdentifier: string From 808282f3bbb4c308bf1711f45edb324e6b5507df Mon Sep 17 00:00:00 2001 From: mfbz Date: Tue, 23 Sep 2025 22:34:21 +0200 Subject: [PATCH 03/19] Added improved implementation of useFlowSchedule hook --- packages/react-sdk/src/constants.ts | 7 + packages/react-sdk/src/hooks/index.ts | 2 +- .../react-sdk/src/hooks/useFlowSchedule.ts | 560 ++++++++++++++++++ .../src/hooks/useFlowScheduleManager.ts | 442 -------------- 4 files changed, 568 insertions(+), 443 deletions(-) create mode 100644 packages/react-sdk/src/hooks/useFlowSchedule.ts delete mode 100644 packages/react-sdk/src/hooks/useFlowScheduleManager.ts diff --git a/packages/react-sdk/src/constants.ts b/packages/react-sdk/src/constants.ts index 0ffc9c721..5359177c8 100644 --- a/packages/react-sdk/src/constants.ts +++ b/packages/react-sdk/src/constants.ts @@ -11,6 +11,8 @@ export const CONTRACT_ADDRESSES = { FlowEVMBridgeUtils: "0xdfc20aee650fcbdf", FlowEVMBridgeConfig: "0xdfc20aee650fcbdf", FungibleTokenMetadataViews: "0x9a0766d93b6608b7", + FlowTransactionScheduler: "0x8c5303eaa26202d6", + FlowTransactionSchedulerUtils: "0x8c5303eaa26202d6", }, mainnet: { EVM: "0xe467b9dd11fa00df", @@ -24,6 +26,9 @@ export const CONTRACT_ADDRESSES = { FlowEVMBridgeUtils: "0x1e4aa0b87d10b141", FlowEVMBridgeConfig: "0x1e4aa0b87d10b141", FungibleTokenMetadataViews: "0xf233dcee88fe0abe", + // TODO: Update with mainnet addresses + FlowTransactionScheduler: "0x0000000000000000", + FlowTransactionSchedulerUtils: "0x0000000000000000", }, local: { EVM: "0xf8d6e0586b0a20c7", @@ -37,6 +42,8 @@ export const CONTRACT_ADDRESSES = { FlowEVMBridgeUtils: "0xf8d6e0586b0a20c7", FlowEVMBridgeConfig: "0xf8d6e0586b0a20c7", FungibleTokenMetadataViews: "0xee82856bf20e2aa6", + FlowTransactionScheduler: "0xf8d6e0586b0a20c7", + FlowTransactionSchedulerUtils: "0xf8d6e0586b0a20c7", }, } diff --git a/packages/react-sdk/src/hooks/index.ts b/packages/react-sdk/src/hooks/index.ts index 92724034c..aba50bb42 100644 --- a/packages/react-sdk/src/hooks/index.ts +++ b/packages/react-sdk/src/hooks/index.ts @@ -15,4 +15,4 @@ export {useFlowTransactionStatus} from "./useFlowTransactionStatus" export {useCrossVmSpendNft} from "./useCrossVmSpendNft" export {useCrossVmSpendToken} from "./useCrossVmSpendToken" export {useCrossVmTransactionStatus} from "./useCrossVmTransactionStatus" -export {useFlowScheduleManager} from "./useFlowScheduleManager" +export {useFlowSchedule} from "./useFlowSchedule" diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.ts b/packages/react-sdk/src/hooks/useFlowSchedule.ts new file mode 100644 index 000000000..cbbe4b0b9 --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowSchedule.ts @@ -0,0 +1,560 @@ +import {arg, t} from "@onflow/fcl" +import {useCallback} from "react" +import {useFlowClient} from "./useFlowClient" +import {useFlowMutate} from "./useFlowMutate" +import {useFlowChainId} from "./useFlowChainId" +import {CONTRACT_ADDRESSES, CADENCE_UFIX64_PRECISION} from "../constants" +import {parseUnits} from "viem/utils" + +export enum TransactionPriority { + Low = 0, + Medium = 1, + High = 2, +} + +export enum TransactionStatus { + Pending = 0, + Processing = 1, + Completed = 2, + Failed = 3, + Cancelled = 4, +} + +export interface TransactionInfo { + id: bigint + priority: TransactionPriority + executionEffort: bigint + status: TransactionStatus + fees: { + value: bigint + formatted: string + } + scheduledTimestamp: number + handlerTypeIdentifier: string + handlerAddress: string +} + +export interface TransactionInfoWithHandler extends TransactionInfo { + handlerUUID: bigint + handlerResolvedViews: {[viewType: string]: any} +} + +export interface UseFlowScheduleArgs { + flowClient?: ReturnType +} + +export interface UseFlowScheduleResult { + // Lists all transactions for an account + // Equivalent to: flow schedule list [--include-handler-data] + list: ( + account: string, + options?: {includeHandlerData?: boolean} + ) => Promise + + // Gets a transaction by ID + // Equivalent to: flow schedule get [--include-handler-data] + get: ( + transactionId: bigint, + options?: {includeHandlerData?: boolean} + ) => Promise + + // Sets up a Manager resource in the signer's account if not already done + // Equivalent to: flow schedule setup [--signer account] + setup: () => Promise + + // Cancels a scheduled transaction by ID + // Equivalent to: flow schedule cancel [--signer account] + cancel: (transactionId: bigint) => Promise +} + +const listTransactionsQuery = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} +import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} + +access(all) struct TransactionInfo { + access(all) let id: UInt64 + access(all) let priority: UInt8 + access(all) let executionEffort: UInt64 + access(all) let status: UInt8 + access(all) let fees: UFix64 + access(all) let scheduledTimestamp: UFix64 + access(all) let handlerTypeIdentifier: String + access(all) let handlerAddress: Address + + init(data: FlowTransactionScheduler.TransactionData) { + self.id = data.id + self.priority = data.priority.rawValue + self.executionEffort = data.executionEffort + self.status = data.status.rawValue + self.fees = data.fees + self.scheduledTimestamp = data.scheduledTimestamp + self.handlerTypeIdentifier = data.handlerTypeIdentifier + self.handlerAddress = data.handlerAddress + } +} + +/// Lists all transactions for an account +/// This script is used by: flow schedule list +access(all) fun main(account: Address): [TransactionInfo] { + // Borrow the Manager + let manager = FlowTransactionSchedulerUtils.borrowManager(at: account) + ?? panic("Could not borrow Manager from account") + + let transactionIds = manager.getTransactionIDs() + var transactions: [TransactionInfo] = [] + + // Get transaction data through the Manager + for id in transactionIds { + if let txData = manager.getTransactionData(id) { + transactions.append(TransactionInfo(data: txData)) + } + } + + return transactions +} +` +} + +const listTransactionsWithHandlerQuery = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} +import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} + +/// Combined transaction info with handler data +access(all) struct TransactionInfoWithHandler { + // Transaction fields + access(all) let id: UInt64 + access(all) let priority: UInt8 + access(all) let executionEffort: UInt64 + access(all) let status: UInt8 + access(all) let fees: UFix64 + access(all) let scheduledTimestamp: UFix64 + access(all) let handlerTypeIdentifier: String + access(all) let handlerAddress: Address + + // Handler fields + access(all) let handlerUUID: UInt64 + access(all) let handlerResolvedViews: {Type: AnyStruct} + + init(data: FlowTransactionScheduler.TransactionData, handlerUUID: UInt64, resolvedViews: {Type: AnyStruct}) { + // Initialize transaction fields + self.id = data.id + self.priority = data.priority.rawValue + self.executionEffort = data.executionEffort + self.status = data.status.rawValue + self.fees = data.fees + self.scheduledTimestamp = data.scheduledTimestamp + self.handlerTypeIdentifier = data.handlerTypeIdentifier + self.handlerAddress = data.handlerAddress + + // Initialize handler fields + self.handlerUUID = handlerUUID + self.handlerResolvedViews = resolvedViews + } +} + +/// Lists all transactions for an account with handler data +/// This script is used by: flow schedule list --include-handler-data +access(all) fun main(account: Address): [TransactionInfoWithHandler] { + // Borrow the Manager + let manager = FlowTransactionSchedulerUtils.borrowManager(at: account) + ?? panic("Could not borrow Manager from account") + + let transactionIds = manager.getTransactionIDs() + var transactions: [TransactionInfoWithHandler] = [] + + // Get transaction data with handler views + for id in transactionIds { + if let txData = manager.getTransactionData(id) { + // Borrow handler to get its UUID + let handler = txData.borrowHandler() + + // Get handler views through the manager + let availableViews = manager.getHandlerViewsFromTransactionID(id) + var resolvedViews: {Type: AnyStruct} = {} + + for viewType in availableViews { + if let resolvedView = manager.resolveHandlerViewFromTransactionID(id, viewType: viewType) { + resolvedViews[viewType] = resolvedView + } + } + + transactions.append(TransactionInfoWithHandler( + data: txData, + handlerUUID: handler.uuid, + resolvedViews: resolvedViews + )) + } + } + + return transactions +} +` +} + +const getTransactionQuery = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} + +access(all) struct TransactionInfo { + access(all) let id: UInt64 + access(all) let priority: UInt8 + access(all) let executionEffort: UInt64 + access(all) let status: UInt8 + access(all) let fees: UFix64 + access(all) let scheduledTimestamp: UFix64 + access(all) let handlerTypeIdentifier: String + access(all) let handlerAddress: Address + + init(data: FlowTransactionScheduler.TransactionData) { + self.id = data.id + self.priority = data.priority.rawValue + self.executionEffort = data.executionEffort + self.status = data.status.rawValue + self.fees = data.fees + self.scheduledTimestamp = data.scheduledTimestamp + self.handlerTypeIdentifier = data.handlerTypeIdentifier + self.handlerAddress = data.handlerAddress + } +} + +/// Gets a transaction by ID (checks globally, not manager-specific) +/// This script is used by: flow schedule get +access(all) fun main(transactionId: UInt64): TransactionInfo? { + // Get transaction data directly from FlowTransactionScheduler + if let txData = FlowTransactionScheduler.getTransactionData(id: transactionId) { + return TransactionInfo(data: txData) + } + return nil +} +` +} + +const getTransactionWithHandlerQuery = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} + +access(all) struct TransactionInfoWithHandler { + access(all) let id: UInt64 + access(all) let priority: UInt8 + access(all) let executionEffort: UInt64 + access(all) let status: UInt8 + access(all) let fees: UFix64 + access(all) let scheduledTimestamp: UFix64 + access(all) let handlerTypeIdentifier: String + access(all) let handlerAddress: Address + + access(all) let handlerUUID: UInt64 + access(all) let handlerResolvedViews: {Type: AnyStruct} + + init(data: FlowTransactionScheduler.TransactionData, handlerUUID: UInt64, resolvedViews: {Type: AnyStruct}) { + // Initialize transaction fields + self.id = data.id + self.priority = data.priority.rawValue + self.executionEffort = data.executionEffort + self.status = data.status.rawValue + self.fees = data.fees + self.scheduledTimestamp = data.scheduledTimestamp + self.handlerTypeIdentifier = data.handlerTypeIdentifier + self.handlerAddress = data.handlerAddress + + self.handlerUUID = handlerUUID + self.handlerResolvedViews = resolvedViews + } +} + +/// Gets a transaction by ID with handler data (checks globally, not manager-specific) +/// This script is used by: flow schedule get --include-handler-data +access(all) fun main(transactionId: UInt64): TransactionInfoWithHandler? { + // Get transaction data directly from FlowTransactionScheduler + if let txData = FlowTransactionScheduler.getTransactionData(id: transactionId) { + // Borrow handler and resolve views + let handler = txData.borrowHandler() + let availableViews = handler.getViews() + var resolvedViews: {Type: AnyStruct} = {} + + for viewType in availableViews { + if let resolvedView = handler.resolveView(viewType) { + resolvedViews[viewType] = resolvedView + } + } + + return TransactionInfoWithHandler( + data: txData, + handlerUUID: handler.uuid, + resolvedViews: resolvedViews + ) + } + + return nil +} +` +} + +const setupManagerMutation = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} + +/// Sets up a Manager resource in the signer's account if not already done +/// This transaction is used by: flow schedule setup [--signer account] +transaction() { + prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue, PublishCapability) &Account) { + // Save a manager resource to storage if not already present + if signer.storage.borrow<&AnyResource>(from: FlowTransactionSchedulerUtils.managerStoragePath) == nil { + let manager <- FlowTransactionSchedulerUtils.createManager() + signer.storage.save(<-manager, to: FlowTransactionSchedulerUtils.managerStoragePath) + } + + // Create a capability for the Manager + let managerCap = signer.capabilities.storage.issue<&{FlowTransactionSchedulerUtils.Manager}>(FlowTransactionSchedulerUtils.managerStoragePath) + signer.capabilities.publish(managerCap, at: FlowTransactionSchedulerUtils.managerPublicPath) + } +} +` +} + +const cancelTransactionMutation = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} +import FlowToken from ${contractAddresses.FlowToken} +import FungibleToken from ${contractAddresses.FungibleToken} + +/// Cancels a scheduled transaction by ID +/// This transaction is used by: flow schedule cancel [--signer account] +transaction(transactionId: UInt64) { + let manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager} + let receiverRef: &{FungibleToken.Receiver} + + prepare(signer: auth(BorrowValue) &Account) { + // Borrow the Manager with Owner entitlement + self.manager = signer.storage.borrow( + from: FlowTransactionSchedulerUtils.managerStoragePath + ) ?? panic("Could not borrow Manager with Owner entitlement from account") + + // Get receiver reference from signer's account + self.receiverRef = signer.capabilities.borrow<&{FungibleToken.Receiver}>(/public/flowTokenReceiver) + ?? panic("Could not borrow receiver reference") + } + + execute { + // Cancel the transaction and receive refunded fees + let refundedFees <- self.manager.cancel(id: transactionId) + + // Deposit refunded fees back to the signer's vault + self.receiverRef.deposit(from: <-refundedFees) + } +} +` +} + +const convertTransactionInfo = (data: any): TransactionInfo => { + return { + id: BigInt(data.id || 0), + priority: Number(data.priority || 0) as TransactionPriority, + executionEffort: BigInt(data.executionEffort || 0), + status: Number(data.status || 0) as TransactionStatus, + fees: { + value: parseUnits(data.fees || "0.0", CADENCE_UFIX64_PRECISION), + formatted: data.fees || "0.0", + }, + scheduledTimestamp: Number(data.scheduledTimestamp || 0), + handlerTypeIdentifier: data.handlerTypeIdentifier || "", + handlerAddress: data.handlerAddress || "", + } +} + +const convertTransactionInfoWithHandler = ( + data: any +): TransactionInfoWithHandler => { + return { + id: BigInt(data.id || 0), + priority: Number(data.priority || 0) as TransactionPriority, + executionEffort: BigInt(data.executionEffort || 0), + status: Number(data.status || 0) as TransactionStatus, + fees: { + value: parseUnits(data.fees || "0.0", CADENCE_UFIX64_PRECISION), + formatted: data.fees || "0.0", + }, + scheduledTimestamp: Number(data.scheduledTimestamp || 0), + handlerTypeIdentifier: data.handlerTypeIdentifier || "", + handlerAddress: data.handlerAddress || "", + handlerUUID: BigInt(data.handlerUUID || 0), + handlerResolvedViews: data.handlerResolvedViews || {}, + } +} + +/** + * Hook for interacting with the Flow Transaction Scheduler, allowing users to schedule, + * manage, and cancel scheduled transactions. + * @param {UseFlowScheduleArgs} args - Optional arguments including a custom Flow client + * @returns {UseFlowScheduleResult} An object containing functions to setup, list, get, and cancel scheduled transactions + */ +export function useFlowSchedule({ + flowClient, +}: UseFlowScheduleArgs = {}): UseFlowScheduleResult { + const fcl = useFlowClient({flowClient}) + const chainIdResult = useFlowChainId() + const chainId = chainIdResult.data + const setupMutation = useFlowMutate({flowClient}) + const cancelMutation = useFlowMutate({flowClient}) + + // List function -> Lists all transactions for an account + const list = useCallback( + async ( + account: string, + options?: {includeHandlerData?: boolean} + ): Promise => { + if (!account) { + throw new Error("Account address is required") + } + + if (!account.startsWith("0x")) { + throw new Error("Account address must start with 0x") + } + + if (!chainId) { + throw new Error("Chain ID not detected") + } + + try { + const cadence = options?.includeHandlerData + ? listTransactionsWithHandlerQuery(chainId) + : listTransactionsQuery(chainId) + + const result = await fcl.query({ + cadence, + args: () => [arg(account, t.Address)], + }) + + if (!Array.isArray(result)) { + return [] + } + + return options?.includeHandlerData + ? result.map(convertTransactionInfoWithHandler) + : result.map(convertTransactionInfo) + } catch (error: any) { + const message = error?.message || "Unknown error" + throw new Error(`Failed to list transactions: ${message}`) + } + }, + [fcl, chainId] + ) + + // Get function -> Gets a specific transaction by ID + const get = useCallback( + async ( + transactionId: bigint, + options?: {includeHandlerData?: boolean} + ): Promise => { + if (!chainId) { + throw new Error("Chain ID not detected") + } + + try { + const cadence = options?.includeHandlerData + ? getTransactionWithHandlerQuery(chainId) + : getTransactionQuery(chainId) + + const result = await fcl.query({ + cadence, + args: () => [arg(transactionId.toString(), t.UInt64)], + }) + + if (!result) { + return null + } + + return options?.includeHandlerData + ? convertTransactionInfoWithHandler(result) + : convertTransactionInfo(result) + } catch (error: any) { + const message = error?.message || "Unknown error" + throw new Error(`Failed to get transaction: ${message}`) + } + }, + [fcl, chainId] + ) + + // Setup function -> Creates manager resource if not exists + const setup = useCallback(async (): Promise => { + if (!chainId) { + throw new Error("Chain ID not detected") + } + try { + const result = await setupMutation.mutateAsync({ + cadence: setupManagerMutation(chainId), + args: () => [], + }) + return result + } catch (error: any) { + const message = error?.message || "Unknown error" + throw new Error(`Failed to setup manager: ${message}`) + } + }, [setupMutation, chainId]) + + // Cancel function -> Cancels a scheduled transaction + const cancel = useCallback( + async (transactionId: bigint): Promise => { + if (!chainId) { + throw new Error("Chain ID not detected") + } + + try { + const result = await cancelMutation.mutateAsync({ + cadence: cancelTransactionMutation(chainId), + args: () => [arg(transactionId.toString(), t.UInt64)], + }) + return result + } catch (error: any) { + const message = error?.message || "Unknown error" + throw new Error(`Failed to cancel transaction: ${message}`) + } + }, + [cancelMutation, chainId] + ) + + return { + list, + get, + setup, + cancel, + } +} diff --git a/packages/react-sdk/src/hooks/useFlowScheduleManager.ts b/packages/react-sdk/src/hooks/useFlowScheduleManager.ts deleted file mode 100644 index 43290cd71..000000000 --- a/packages/react-sdk/src/hooks/useFlowScheduleManager.ts +++ /dev/null @@ -1,442 +0,0 @@ -import {arg, t} from "@onflow/fcl" -import {UseQueryOptions} from "@tanstack/react-query" -import {useCallback, useMemo} from "react" -import {useFlowQueryClient} from "../provider/FlowQueryClient" -import {useFlowChainId} from "./useFlowChainId" -import {useFlowClient} from "./useFlowClient" -import {useFlowMutate} from "./useFlowMutate" -import {useFlowQuery} from "./useFlowQuery" - -export enum TransactionPriority { - High = 0, - Medium = 1, - Low = 2, -} - -export enum TransactionStatus { - Unknown = 0, - Scheduled = 1, - Executed = 2, - Canceled = 3, -} - -export interface TransactionData { - id: string - priority: TransactionPriority - executionEffort: string - status: TransactionStatus - fees: string - scheduledTimestamp: string - handlerTypeIdentifier: string - handlerAddress: string - // Optional handler data when includeTransactionHandler is true - handler?: HandlerData -} - -export interface HandlerData { - typeIdentifier: string - // Not yet a standard so we need to let the client decide the type - metadata: {[viewType: string]: any} - // Optional transactions when includeHandlerTransactions is true - transactions?: TransactionData[] -} - -export interface HandlerCollection { - [typeIdentifier: string]: HandlerData -} - -export interface ScheduleManagerFilter { - // Include transactions for each handler - handlerTransactions?: boolean - // Include handler for each transaction - transactionHandler?: boolean -} - -export interface UseFlowScheduleManagerArgs { - // Manager address - address?: string - // Filter options for including related data - include?: ScheduleManagerFilter - // React Query settings - query?: Omit, "queryKey" | "queryFn"> - flowClient?: ReturnType -} - -export interface UseFlowScheduleManagerData { - transactions: TransactionData[] - handlers: HandlerCollection -} - -export interface UseFlowScheduleManagerResult { - data: UseFlowScheduleManagerData | null - isLoading: boolean - isFetching: boolean - error: Error | null - refetch: () => void - - // Query scripts - scripts: { - getManagerTransactions: ( - address: string, - includeHandler?: boolean - ) => Promise - getManagerHandlers: ( - address: string, - includeTransactions?: boolean - ) => Promise - getHandlerTransactions: ( - address: string, - handlerTypeIdentifier: string - ) => Promise - getTransactionHandler: ( - address: string, - transactionId: string - ) => Promise - } - - // Transaction mutations (will trigger refetch of data) - transactions: { - cancel: (address: string, transactionId: string) => Promise - cleanup: (address: string) => Promise - } -} - -// Cadence scripts with placeholders -const getManagerTransactionsInternal = ( - network: "testnet" | "mainnet" | "local", - includeHandler: boolean -) => ` -// TODO -` - -const getManagerHandlersInternal = ( - network: "testnet" | "mainnet" | "local", - includeTransactions: boolean -) => ` -// TODO -` - -const getHandlerTransactionsInternal = ( - network: "testnet" | "mainnet" | "local" -) => ` -// TODO -` - -const getTransactionHandlerInternal = ( - network: "testnet" | "mainnet" | "local" -) => ` -// TODO -` - -const cancelTransaction = (network: "testnet" | "mainnet" | "local") => ` -// TODO -` - -const cleanupManager = (network: "testnet" | "mainnet" | "local") => ` -// TODO -` - -/** - * Fetches and manages scheduled transactions for a Flow account - * - * @param args.address - The manager address to fetch data for - * @param args.include - Filter options for including related data - * @param args.query - Optional React Query options - * @param args.flowClient - Optional custom flow client - * - * @example - * // Auto-fetch data for an address - * const manager = useFlowScheduleManager({ - * address: "0x123...", - * include: { handlerTransactions: true } - * }) - * - * // Use without auto-fetching - * const manager = useFlowScheduleManager() - */ -export function useFlowScheduleManager({ - address, - include = {}, - query: queryOptions = {}, - flowClient, -}: UseFlowScheduleManagerArgs = {}): UseFlowScheduleManagerResult { - const chainIdResult = useFlowChainId() - const fcl = useFlowClient({flowClient}) - const queryClient = useFlowQueryClient() - - const network = chainIdResult.data as - | "testnet" - | "mainnet" - | "local" - | undefined - - // Internal query functions that can be reused - const queryAllTransactions = useCallback( - async ( - targetAddress: string, - includeHandler: boolean = false - ): Promise => { - if (!network) throw new Error("Network not detected") - if (!targetAddress) throw new Error("Address is required") - - try { - const result = await fcl.query({ - cadence: getManagerTransactionsInternal(network, includeHandler), - args: () => [arg(targetAddress, t.Address)], - }) - return (result as TransactionData[]) || [] - } catch (error) { - console.error("Failed to fetch transactions:", error) - throw error - } - }, - [network, fcl] - ) - - const queryAllHandlers = useCallback( - async ( - targetAddress: string, - includeTransactions: boolean = false - ): Promise => { - if (!network) throw new Error("Network not detected") - if (!targetAddress) throw new Error("Address is required") - - try { - const result = await fcl.query({ - cadence: getManagerHandlersInternal(network, includeTransactions), - args: () => [arg(targetAddress, t.Address)], - }) - return (result as HandlerCollection) || {} - } catch (error) { - console.error("Failed to fetch handlers:", error) - throw error - } - }, - [network, fcl] - ) - - // Auto-fetch transactions using the internal query function - const transactionsResult = useFlowQuery({ - cadence: - network && address - ? getManagerTransactionsInternal( - network, - include.transactionHandler || false - ) - : "", - args: () => (address ? [arg(address, t.Address)] : []), - query: { - ...queryOptions, - enabled: (queryOptions.enabled ?? true) && !!network && !!address, - }, - flowClient: fcl, - }) - - // Auto-fetch handlers using the internal query function - const handlersResult = useFlowQuery({ - cadence: - network && address - ? getManagerHandlersInternal( - network, - include.handlerTransactions || false - ) - : "", - args: () => (address ? [arg(address, t.Address)] : []), - query: { - ...queryOptions, - enabled: (queryOptions.enabled ?? true) && !!network && !!address, - }, - flowClient: fcl, - }) - - // Memoized data - const data = useMemo(() => { - if (!transactionsResult.data && !handlersResult.data) { - return null - } - - return { - transactions: (transactionsResult.data as TransactionData[]) || [], - handlers: (handlersResult.data as HandlerCollection) || {}, - } - }, [transactionsResult.data, handlersResult.data]) - - // Refetch function - const refetch = useCallback(async () => { - await Promise.all([transactionsResult.refetch(), handlersResult.refetch()]) - }, [transactionsResult, handlersResult]) - - // Exposed script methods that reuse the internal query functions - const getManagerTransactions = useCallback( - async ( - address: string, - includeHandler?: boolean - ): Promise => { - return queryAllTransactions(address, includeHandler || false) - }, - [queryAllTransactions] - ) - - const getManagerHandlers = useCallback( - async ( - address: string, - includeTransactions?: boolean - ): Promise => { - return queryAllHandlers(address, includeTransactions || false) - }, - [queryAllHandlers] - ) - - const getHandlerTransactions = useCallback( - async ( - address: string, - handlerTypeIdentifier: string - ): Promise => { - if (!network) throw new Error("Network not detected") - if (!address) throw new Error("Address is required") - if (!handlerTypeIdentifier) - throw new Error("Handler type identifier is required") - - try { - const result = await fcl.query({ - cadence: getHandlerTransactionsInternal(network), - args: () => [ - arg(address, t.Address), - arg(handlerTypeIdentifier, t.String), - ], - }) - return (result as TransactionData[]) || [] - } catch (error) { - console.error("Failed to fetch handler transactions:", error) - throw error - } - }, - [network, fcl] - ) - - const getTransactionHandler = useCallback( - async ( - address: string, - transactionId: string - ): Promise => { - if (!network) throw new Error("Network not detected") - if (!address) throw new Error("Address is required") - if (!transactionId) throw new Error("Transaction ID is required") - - try { - const result = await fcl.query({ - cadence: getTransactionHandlerInternal(network), - args: () => [arg(address, t.Address), arg(transactionId, t.UInt64)], - }) - return result as HandlerData | null - } catch (error) { - console.error("Failed to fetch transaction handler:", error) - throw error - } - }, - [network, fcl] - ) - - // Mutations - const cancelTransactionMutation = useFlowMutate({flowClient: fcl}) - const cleanupMutation = useFlowMutate({flowClient: fcl}) - - // Invalidate queries helper - const invalidateQueries = useCallback(() => { - if (address && network) { - queryClient.invalidateQueries({ - queryKey: [ - "flowQuery", - getManagerTransactionsInternal( - network, - include.transactionHandler || false - ), - ], - }) - queryClient.invalidateQueries({ - queryKey: [ - "flowQuery", - getManagerHandlersInternal( - network, - include.handlerTransactions || false - ), - ], - }) - } - }, [ - address, - network, - include.transactionHandler, - include.handlerTransactions, - queryClient, - ]) - - const cancel = useCallback( - async (targetAddress: string, transactionId: string): Promise => { - if (!network) throw new Error("Network not detected") - if (!targetAddress) throw new Error("Target address is required") - if (!transactionId) throw new Error("Transaction ID is required") - - try { - const result = await cancelTransactionMutation.mutateAsync({ - cadence: cancelTransaction(network), - args: () => [arg(transactionId, t.UInt64)], - }) - - // Refetch data if the cancel was for the currently watched address - if (targetAddress === address) { - await invalidateQueries() - } - - return result - } catch (error) { - console.error("Failed to cancel transaction:", error) - throw error - } - }, - [network, cancelTransactionMutation, address, invalidateQueries] - ) - - const cleanup = useCallback( - async (targetAddress: string): Promise => { - if (!network) throw new Error("Network not detected") - if (!targetAddress) throw new Error("Target address is required") - - try { - const result = await cleanupMutation.mutateAsync({ - cadence: cleanupManager(network), - args: () => [], - }) - - // Refetch data if the cleanup was for the currently watched address - if (targetAddress === address) { - await invalidateQueries() - } - - return result - } catch (error) { - console.error("Failed to cleanup manager:", error) - throw error - } - }, - [network, cleanupMutation, address, invalidateQueries] - ) - - return { - data, - isLoading: transactionsResult.isLoading || handlersResult.isLoading, - isFetching: transactionsResult.isFetching || handlersResult.isFetching, - error: transactionsResult.error || handlersResult.error, - refetch, - scripts: { - getManagerTransactions, - getManagerHandlers, - getHandlerTransactions, - getTransactionHandler, - }, - transactions: { - cancel, - cleanup, - }, - } -} From 0374705c5c8cf1c30e5899bf46720ff131ca68f3 Mon Sep 17 00:00:00 2001 From: mfbz Date: Wed, 1 Oct 2025 18:52:45 +0200 Subject: [PATCH 04/19] Cleaned up hook --- .../react-sdk/src/hooks/useFlowSchedule.ts | 35 ++++--------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.ts b/packages/react-sdk/src/hooks/useFlowSchedule.ts index cbbe4b0b9..bb17aab88 100644 --- a/packages/react-sdk/src/hooks/useFlowSchedule.ts +++ b/packages/react-sdk/src/hooks/useFlowSchedule.ts @@ -441,17 +441,7 @@ export function useFlowSchedule({ account: string, options?: {includeHandlerData?: boolean} ): Promise => { - if (!account) { - throw new Error("Account address is required") - } - - if (!account.startsWith("0x")) { - throw new Error("Account address must start with 0x") - } - - if (!chainId) { - throw new Error("Chain ID not detected") - } + if (!chainId) throw new Error("Chain ID not detected") try { const cadence = options?.includeHandlerData @@ -463,10 +453,7 @@ export function useFlowSchedule({ args: () => [arg(account, t.Address)], }) - if (!Array.isArray(result)) { - return [] - } - + if (!Array.isArray(result)) return [] return options?.includeHandlerData ? result.map(convertTransactionInfoWithHandler) : result.map(convertTransactionInfo) @@ -484,9 +471,7 @@ export function useFlowSchedule({ transactionId: bigint, options?: {includeHandlerData?: boolean} ): Promise => { - if (!chainId) { - throw new Error("Chain ID not detected") - } + if (!chainId) throw new Error("Chain ID not detected") try { const cadence = options?.includeHandlerData @@ -498,10 +483,7 @@ export function useFlowSchedule({ args: () => [arg(transactionId.toString(), t.UInt64)], }) - if (!result) { - return null - } - + if (!result) return null return options?.includeHandlerData ? convertTransactionInfoWithHandler(result) : convertTransactionInfo(result) @@ -515,9 +497,8 @@ export function useFlowSchedule({ // Setup function -> Creates manager resource if not exists const setup = useCallback(async (): Promise => { - if (!chainId) { - throw new Error("Chain ID not detected") - } + if (!chainId) throw new Error("Chain ID not detected") + try { const result = await setupMutation.mutateAsync({ cadence: setupManagerMutation(chainId), @@ -533,9 +514,7 @@ export function useFlowSchedule({ // Cancel function -> Cancels a scheduled transaction const cancel = useCallback( async (transactionId: bigint): Promise => { - if (!chainId) { - throw new Error("Chain ID not detected") - } + if (!chainId) throw new Error("Chain ID not detected") try { const result = await cancelMutation.mutateAsync({ From 77295d3f1056a891230d5b4c3de846ea4c5f2f11 Mon Sep 17 00:00:00 2001 From: mfbz Date: Wed, 1 Oct 2025 22:05:50 +0200 Subject: [PATCH 05/19] Added tests --- .../src/hooks/useFlowSchedule.test.ts | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 packages/react-sdk/src/hooks/useFlowSchedule.test.ts diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.test.ts b/packages/react-sdk/src/hooks/useFlowSchedule.test.ts new file mode 100644 index 000000000..2dc07ce01 --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowSchedule.test.ts @@ -0,0 +1,310 @@ +import * as fcl from "@onflow/fcl" +import {renderHook} from "@testing-library/react" +import {createMockFclInstance, MockFclInstance} from "../__mocks__/flow-client" +import {FlowProvider} from "../provider" +import {useFlowChainId} from "./useFlowChainId" +import { + TransactionInfoWithHandler, + TransactionPriority, + TransactionStatus, + useFlowSchedule, +} from "./useFlowSchedule" + +jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) +jest.mock("./useFlowChainId", () => ({ + useFlowChainId: jest.fn(), +})) + +describe("useFlowSchedule", () => { + let mockFcl: MockFclInstance + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + isLoading: false, + } as any) + + mockFcl = createMockFclInstance() + jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) + }) + + describe("list", () => { + test("lists transactions for an account", async () => { + const mockTransactions = [ + { + id: "1", + priority: 1, + executionEffort: "100", + status: 0, + fees: "0.001", + scheduledTimestamp: "1234567890.0", + handlerTypeIdentifier: "A.123.Handler", + handlerAddress: "0x123", + }, + ] + + jest + .mocked(mockFcl.mockFclInstance.query) + .mockResolvedValueOnce(mockTransactions) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + const transactions = await result.current.list("0xACCOUNT") + + expect(transactions).toHaveLength(1) + expect(transactions[0].id).toBe(1n) + expect(transactions[0].priority).toBe(TransactionPriority.Medium) + expect(transactions[0].status).toBe(TransactionStatus.Pending) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("lists transactions with handler data", async () => { + const mockTransactionsWithHandler = [ + { + id: "1", + priority: 2, + executionEffort: "200", + status: 1, + fees: "0.002", + scheduledTimestamp: "1234567890.0", + handlerTypeIdentifier: "A.456.Handler", + handlerAddress: "0x456", + handlerUUID: "9999", + handlerResolvedViews: {display: {name: "Test"}}, + }, + ] + + jest + .mocked(mockFcl.mockFclInstance.query) + .mockResolvedValueOnce(mockTransactionsWithHandler) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + const transactions = (await result.current.list("0xACCOUNT", { + includeHandlerData: true, + })) as TransactionInfoWithHandler[] + + expect(transactions).toHaveLength(1) + expect(transactions[0].id).toBe(1n) + expect(transactions[0].handlerUUID).toBe(9999n) + expect(transactions[0].handlerResolvedViews).toEqual({ + display: {name: "Test"}, + }) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("returns empty array when no transactions", async () => { + jest.mocked(mockFcl.mockFclInstance.query).mockResolvedValueOnce([]) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + const transactions = await result.current.list("0xACCOUNT") + + expect(transactions).toEqual([]) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("throws error when chain ID not detected", async () => { + jest.mocked(useFlowChainId).mockReturnValue({ + data: null, + isLoading: false, + } as any) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + await expect(result.current.list("0xACCOUNT")).rejects.toThrow( + "Chain ID not detected" + ) + }) + + test("handles query errors", async () => { + const error = new Error("Query failed") + jest.mocked(mockFcl.mockFclInstance.query).mockRejectedValueOnce(error) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + await expect(result.current.list("0xACCOUNT")).rejects.toThrow( + "Failed to list transactions: Query failed" + ) + }) + }) + + describe("get", () => { + test("gets a transaction by ID", async () => { + const mockTransaction = { + id: "42", + priority: 0, + executionEffort: "50", + status: 2, + fees: "0.0005", + scheduledTimestamp: "1234567890.0", + handlerTypeIdentifier: "A.789.Handler", + handlerAddress: "0x789", + } + + jest + .mocked(mockFcl.mockFclInstance.query) + .mockResolvedValueOnce(mockTransaction) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + const transaction = await result.current.get(42n) + + expect(transaction).toBeDefined() + expect(transaction?.id).toBe(42n) + expect(transaction?.priority).toBe(TransactionPriority.Low) + expect(transaction?.status).toBe(TransactionStatus.Completed) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("gets a transaction with handler data", async () => { + const mockTransactionWithHandler = { + id: "42", + priority: 1, + executionEffort: "100", + status: 0, + fees: "0.001", + scheduledTimestamp: "1234567890.0", + handlerTypeIdentifier: "A.789.Handler", + handlerAddress: "0x789", + handlerUUID: "5555", + handlerResolvedViews: {metadata: {description: "Test handler"}}, + } + + jest + .mocked(mockFcl.mockFclInstance.query) + .mockResolvedValueOnce(mockTransactionWithHandler) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + const transaction = (await result.current.get(42n, { + includeHandlerData: true, + })) as TransactionInfoWithHandler + + expect(transaction).toBeDefined() + expect(transaction.id).toBe(42n) + expect(transaction.handlerUUID).toBe(5555n) + expect(transaction.handlerResolvedViews).toEqual({ + metadata: {description: "Test handler"}, + }) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("returns null when transaction not found", async () => { + jest.mocked(mockFcl.mockFclInstance.query).mockResolvedValueOnce(null) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + const transaction = await result.current.get(999n) + + expect(transaction).toBeNull() + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("handles query errors", async () => { + const error = new Error("Query failed") + jest.mocked(mockFcl.mockFclInstance.query).mockRejectedValueOnce(error) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + await expect(result.current.get(42n)).rejects.toThrow( + "Failed to get transaction: Query failed" + ) + }) + }) + + describe("setup", () => { + test("sets up manager successfully", async () => { + const txId = "setup-tx-id-123" + jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + const returnedTxId = await result.current.setup() + + expect(returnedTxId).toBe(txId) + expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() + }) + + test("handles setup errors", async () => { + const error = new Error("Setup failed") + jest.mocked(mockFcl.mockFclInstance.mutate).mockRejectedValueOnce(error) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + await expect(result.current.setup()).rejects.toThrow( + "Failed to setup manager: Setup failed" + ) + }) + }) + + describe("cancel", () => { + test("cancels a transaction successfully", async () => { + const txId = "cancel-tx-id-456" + jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + const returnedTxId = await result.current.cancel(42n) + + expect(returnedTxId).toBe(txId) + expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() + }) + + test("handles cancel errors", async () => { + const error = new Error("Cancel failed") + jest.mocked(mockFcl.mockFclInstance.mutate).mockRejectedValueOnce(error) + + const {result} = renderHook(() => useFlowSchedule(), { + wrapper: FlowProvider, + }) + + await expect(result.current.cancel(42n)).rejects.toThrow( + "Failed to cancel transaction: Cancel failed" + ) + }) + }) + + test("uses custom flowClient when provided", async () => { + const customMockFcl = createMockFclInstance() + const customFlowClient = customMockFcl.mockFclInstance as any + + jest.mocked(customFlowClient.query).mockResolvedValueOnce([]) + + const {result} = renderHook( + () => useFlowSchedule({flowClient: customFlowClient}), + { + wrapper: FlowProvider, + } + ) + + await result.current.list("0xACCOUNT") + + expect(customFlowClient.query).toHaveBeenCalled() + }) +}) From d62421dcdeb375d0d73ac12c87a978837cae0f63 Mon Sep 17 00:00:00 2001 From: mfbz Date: Wed, 1 Oct 2025 22:06:28 +0200 Subject: [PATCH 06/19] Added changeset --- .changeset/upset-cities-start.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/upset-cities-start.md diff --git a/.changeset/upset-cities-start.md b/.changeset/upset-cities-start.md new file mode 100644 index 000000000..200290674 --- /dev/null +++ b/.changeset/upset-cities-start.md @@ -0,0 +1,5 @@ +--- +"@onflow/react-sdk": minor +--- + +Added useFlowSchedule hook From cbc1d785bb9059ddbf846f9f63e507e5e786b5f3 Mon Sep 17 00:00:00 2001 From: mfbz Date: Thu, 2 Oct 2025 10:55:28 +0200 Subject: [PATCH 07/19] Improved changeset description --- .changeset/upset-cities-start.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/upset-cities-start.md b/.changeset/upset-cities-start.md index 200290674..36a3ad05f 100644 --- a/.changeset/upset-cities-start.md +++ b/.changeset/upset-cities-start.md @@ -2,4 +2,4 @@ "@onflow/react-sdk": minor --- -Added useFlowSchedule hook +Added `useFlowSchedule` hook for managing scheduled transactions. This hook provides methods to list, get, setup, and cancel scheduled transactions with support for handler data resolution and transaction status tracking. From 6950c0bdca88f037c79ba8eda0a208212bc816e2 Mon Sep 17 00:00:00 2001 From: mfbz Date: Thu, 2 Oct 2025 11:12:20 +0200 Subject: [PATCH 08/19] Changed transactionId to txId for consistency --- .../react-sdk/src/hooks/useFlowSchedule.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.ts b/packages/react-sdk/src/hooks/useFlowSchedule.ts index bb17aab88..59854646b 100644 --- a/packages/react-sdk/src/hooks/useFlowSchedule.ts +++ b/packages/react-sdk/src/hooks/useFlowSchedule.ts @@ -54,7 +54,7 @@ export interface UseFlowScheduleResult { // Gets a transaction by ID // Equivalent to: flow schedule get [--include-handler-data] get: ( - transactionId: bigint, + txId: bigint, options?: {includeHandlerData?: boolean} ) => Promise @@ -64,7 +64,7 @@ export interface UseFlowScheduleResult { // Cancels a scheduled transaction by ID // Equivalent to: flow schedule cancel [--signer account] - cancel: (transactionId: bigint) => Promise + cancel: (txId: bigint) => Promise } const listTransactionsQuery = (chainId: string) => { @@ -107,11 +107,11 @@ access(all) fun main(account: Address): [TransactionInfo] { let manager = FlowTransactionSchedulerUtils.borrowManager(at: account) ?? panic("Could not borrow Manager from account") - let transactionIds = manager.getTransactionIDs() + let txIds = manager.getTransactionIDs() var transactions: [TransactionInfo] = [] // Get transaction data through the Manager - for id in transactionIds { + for id in txIds { if let txData = manager.getTransactionData(id) { transactions.append(TransactionInfo(data: txData)) } @@ -173,11 +173,11 @@ access(all) fun main(account: Address): [TransactionInfoWithHandler] { let manager = FlowTransactionSchedulerUtils.borrowManager(at: account) ?? panic("Could not borrow Manager from account") - let transactionIds = manager.getTransactionIDs() + let txIds = manager.getTransactionIDs() var transactions: [TransactionInfoWithHandler] = [] // Get transaction data with handler views - for id in transactionIds { + for id in txIds { if let txData = manager.getTransactionData(id) { // Borrow handler to get its UUID let handler = txData.borrowHandler() @@ -239,9 +239,9 @@ access(all) struct TransactionInfo { /// Gets a transaction by ID (checks globally, not manager-specific) /// This script is used by: flow schedule get -access(all) fun main(transactionId: UInt64): TransactionInfo? { +access(all) fun main(txId: UInt64): TransactionInfo? { // Get transaction data directly from FlowTransactionScheduler - if let txData = FlowTransactionScheduler.getTransactionData(id: transactionId) { + if let txData = FlowTransactionScheduler.getTransactionData(id: txId) { return TransactionInfo(data: txData) } return nil @@ -290,9 +290,9 @@ access(all) struct TransactionInfoWithHandler { /// Gets a transaction by ID with handler data (checks globally, not manager-specific) /// This script is used by: flow schedule get --include-handler-data -access(all) fun main(transactionId: UInt64): TransactionInfoWithHandler? { +access(all) fun main(txId: UInt64): TransactionInfoWithHandler? { // Get transaction data directly from FlowTransactionScheduler - if let txData = FlowTransactionScheduler.getTransactionData(id: transactionId) { + if let txData = FlowTransactionScheduler.getTransactionData(id: txId) { // Borrow handler and resolve views let handler = txData.borrowHandler() let availableViews = handler.getViews() @@ -358,7 +358,7 @@ import FungibleToken from ${contractAddresses.FungibleToken} /// Cancels a scheduled transaction by ID /// This transaction is used by: flow schedule cancel [--signer account] -transaction(transactionId: UInt64) { +transaction(txId: UInt64) { let manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager} let receiverRef: &{FungibleToken.Receiver} @@ -375,7 +375,7 @@ transaction(transactionId: UInt64) { execute { // Cancel the transaction and receive refunded fees - let refundedFees <- self.manager.cancel(id: transactionId) + let refundedFees <- self.manager.cancel(id: txId) // Deposit refunded fees back to the signer's vault self.receiverRef.deposit(from: <-refundedFees) @@ -468,7 +468,7 @@ export function useFlowSchedule({ // Get function -> Gets a specific transaction by ID const get = useCallback( async ( - transactionId: bigint, + txId: bigint, options?: {includeHandlerData?: boolean} ): Promise => { if (!chainId) throw new Error("Chain ID not detected") @@ -480,7 +480,7 @@ export function useFlowSchedule({ const result = await fcl.query({ cadence, - args: () => [arg(transactionId.toString(), t.UInt64)], + args: () => [arg(txId.toString(), t.UInt64)], }) if (!result) return null @@ -513,13 +513,13 @@ export function useFlowSchedule({ // Cancel function -> Cancels a scheduled transaction const cancel = useCallback( - async (transactionId: bigint): Promise => { + async (txId: bigint): Promise => { if (!chainId) throw new Error("Chain ID not detected") try { const result = await cancelMutation.mutateAsync({ cadence: cancelTransactionMutation(chainId), - args: () => [arg(transactionId.toString(), t.UInt64)], + args: () => [arg(txId.toString(), t.UInt64)], }) return result } catch (error: any) { From 70440a3cfc8aae624f3c25a0ffc0aae50d070d57 Mon Sep 17 00:00:00 2001 From: mfbz Date: Thu, 2 Oct 2025 22:44:19 +0200 Subject: [PATCH 09/19] Added useFlowSchedule card to demo and improved naming --- .../demo/src/components/content-section.tsx | 2 + .../demo/src/components/content-sidebar.tsx | 6 + .../hook-cards/use-flow-schedule-card.tsx | 392 ++++++++++++++++++ packages/react-sdk/src/hooks/index.ts | 8 +- .../src/hooks/useFlowSchedule.test.ts | 54 +-- .../react-sdk/src/hooks/useFlowSchedule.ts | 90 ++-- 6 files changed, 479 insertions(+), 73 deletions(-) create mode 100644 packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx diff --git a/packages/demo/src/components/content-section.tsx b/packages/demo/src/components/content-section.tsx index 75928a2e5..ae2c53717 100644 --- a/packages/demo/src/components/content-section.tsx +++ b/packages/demo/src/components/content-section.tsx @@ -12,6 +12,7 @@ import {UseFlowMutateCard} from "./hook-cards/use-flow-mutate-card" import {UseFlowEventsCard} from "./hook-cards/use-flow-events-card" import {UseFlowTransactionStatusCard} from "./hook-cards/use-flow-transaction-status-card" import {UseFlowRevertibleRandomCard} from "./hook-cards/use-flow-revertible-random-card" +import {UseFlowScheduleCard} from "./hook-cards/use-flow-schedule-card" // Import setup cards import {InstallationCard} from "./setup-cards/installation-card" @@ -85,6 +86,7 @@ export function ContentSection() { +
diff --git a/packages/demo/src/components/content-sidebar.tsx b/packages/demo/src/components/content-sidebar.tsx index 29d88da5e..f8b22a5c5 100644 --- a/packages/demo/src/components/content-sidebar.tsx +++ b/packages/demo/src/components/content-sidebar.tsx @@ -110,6 +110,12 @@ const sidebarItems: SidebarItem[] = [ category: "hooks", description: "Track transaction status", }, + { + id: "hook-flow-schedule", + label: "Flow Schedule", + category: "hooks", + description: "Manage scheduled transactions", + }, // Advanced section { diff --git a/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx b/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx new file mode 100644 index 000000000..31f846d61 --- /dev/null +++ b/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx @@ -0,0 +1,392 @@ +import { + useFlowCurrentUser, + useFlowSchedule, + ScheduledTxPriority, + ScheduledTxStatus, +} from "@onflow/react-sdk" +import {useState} from "react" +import {useDarkMode} from "../flow-provider-wrapper" +import {DemoCard} from "../ui/demo-card" +import {ResultsSection} from "../ui/results-section" + +const IMPLEMENTATION_CODE = `import { useFlowSchedule } from "@onflow/react-sdk" + +const { + setupScheduler, + listScheduledTx, + getScheduledTx, + cancelScheduledTx +} = useFlowSchedule() + +// Setup manager +await setupScheduler() + +// List all scheduled transactions +const transactions = await listScheduledTx("0xACCOUNT") + +// Get specific transaction +const tx = await getScheduledTx(123n) + +// Cancel a transaction +await cancelScheduledTx(123n)` + +const PRIORITY_LABELS: Record = { + [ScheduledTxPriority.Low]: "Low", + [ScheduledTxPriority.Medium]: "Medium", + [ScheduledTxPriority.High]: "High", +} + +const STATUS_LABELS: Record = { + [ScheduledTxStatus.Pending]: "Pending", + [ScheduledTxStatus.Processing]: "Processing", + [ScheduledTxStatus.Completed]: "Completed", + [ScheduledTxStatus.Failed]: "Failed", + [ScheduledTxStatus.Cancelled]: "Cancelled", +} + +export function UseFlowScheduleCard() { + const {darkMode} = useDarkMode() + const {user} = useFlowCurrentUser() + const {listScheduledTx, getScheduledTx, setupScheduler, cancelScheduledTx} = + useFlowSchedule() + + const [activeTab, setActiveTab] = useState< + "setup" | "list" | "get" | "cancel" + >("setup") + const [txId, setTxId] = useState("") + const [accountAddress, setAccountAddress] = useState("") + const [includeHandlerData, setIncludeHandlerData] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSetup = async () => { + setLoading(true) + setError(null) + setResult(null) + try { + const txId = await setupScheduler() + setResult({txId, message: "Manager setup successfully"}) + } catch (err: any) { + setError(err.message || "Setup failed") + } finally { + setLoading(false) + } + } + + const handleList = async () => { + const address = accountAddress || user?.addr + + if (!address) { + setError("Please connect your wallet or enter an account address") + return + } + + setLoading(true) + setError(null) + setResult(null) + try { + const transactions = await listScheduledTx(address, {includeHandlerData}) + setResult({ + account: address, + count: transactions.length, + transactions, + }) + } catch (err: any) { + setError(err.message || "Failed to list transactions") + } finally { + setLoading(false) + } + } + + const handleGet = async () => { + if (!txId) { + setError("Please enter a transaction ID") + return + } + + setLoading(true) + setError(null) + setResult(null) + try { + const transaction = await getScheduledTx(BigInt(txId), { + includeHandlerData, + }) + setResult(transaction || {message: "Transaction not found"}) + } catch (err: any) { + setError(err.message || "Failed to get transaction") + } finally { + setLoading(false) + } + } + + const handleCancel = async () => { + if (!txId) { + setError("Please enter a transaction ID") + return + } + + setLoading(true) + setError(null) + setResult(null) + try { + const cancelTxId = await cancelScheduledTx(BigInt(txId)) + setResult({ + txId: cancelTxId, + message: "Transaction cancelled successfully", + }) + } catch (err: any) { + setError(err.message || "Failed to cancel transaction") + } finally { + setLoading(false) + } + } + + const formatTransactionInfo = (tx: any) => { + if (!tx.id) return tx + + return { + ID: tx.id.toString(), + Priority: + PRIORITY_LABELS[tx.priority as ScheduledTxPriority] || tx.priority, + Status: STATUS_LABELS[tx.status as ScheduledTxStatus] || tx.status, + "Execution Effort": tx.executionEffort.toString(), + "Fees (FLOW)": tx.fees.formatted, + "Scheduled At": new Date(tx.scheduledTimestamp * 1000).toLocaleString(), + "Handler Type": tx.handlerTypeIdentifier, + "Handler Address": tx.handlerAddress, + ...(tx.handlerUUID && {"Handler UUID": tx.handlerUUID.toString()}), + ...(tx.handlerResolvedViews && { + "Handler Views": Object.keys(tx.handlerResolvedViews).length, + }), + } + } + + return ( + +
+
+ {(["setup", "list", "get", "cancel"] as const).map(tab => ( + + ))} +
+ + {activeTab === "setup" && ( +
+

+ Initialize the Transaction Scheduler Manager in your account +

+ +
+ )} + + {activeTab === "list" && ( +
+
+ + setAccountAddress(e.target.value)} + placeholder={ + user?.addr + ? `Default: ${user.addr}` + : "e.g., 0x1234567890abcdef" + } + className={`w-full px-4 py-3 rounded-lg border font-mono text-sm transition-all duration-200 + ${ + darkMode + ? `bg-gray-900/50 border-white/10 text-white placeholder-gray-500 + focus:border-flow-primary/50` + : `bg-white border-black/10 text-black placeholder-gray-400 + focus:border-flow-primary/50` + } outline-none`} + /> +

+ Leave empty to use connected wallet address +

+
+
+ setIncludeHandlerData(e.target.checked)} + className="w-4 h-4" + /> + +
+ +
+ )} + + {activeTab === "get" && ( +
+
+ + setTxId(e.target.value)} + placeholder="e.g., 123" + className={`w-full px-4 py-3 rounded-lg border font-mono text-sm transition-all duration-200 + ${ + darkMode + ? `bg-gray-900/50 border-white/10 text-white placeholder-gray-500 + focus:border-flow-primary/50` + : `bg-white border-black/10 text-black placeholder-gray-400 + focus:border-flow-primary/50` + } outline-none`} + /> +
+
+ setIncludeHandlerData(e.target.checked)} + className="w-4 h-4" + /> + +
+ +
+ )} + + {activeTab === "cancel" && ( +
+
+ + setTxId(e.target.value)} + placeholder="e.g., 123" + className={`w-full px-4 py-3 rounded-lg border font-mono text-sm transition-all duration-200 + ${ + darkMode + ? `bg-gray-900/50 border-white/10 text-white placeholder-gray-500 + focus:border-flow-primary/50` + : `bg-white border-black/10 text-black placeholder-gray-400 + focus:border-flow-primary/50` + } outline-none`} + /> +
+ +
+ )} + + {(result || error) && ( + + )} +
+
+ ) +} diff --git a/packages/react-sdk/src/hooks/index.ts b/packages/react-sdk/src/hooks/index.ts index a404a416c..7b287fdf6 100644 --- a/packages/react-sdk/src/hooks/index.ts +++ b/packages/react-sdk/src/hooks/index.ts @@ -16,4 +16,10 @@ export {useFlowTransactionStatus} from "./useFlowTransactionStatus" export {useCrossVmSpendNft} from "./useCrossVmSpendNft" export {useCrossVmSpendToken} from "./useCrossVmSpendToken" export {useCrossVmTransactionStatus} from "./useCrossVmTransactionStatus" -export {useFlowSchedule} from "./useFlowSchedule" +export { + useFlowSchedule, + ScheduledTxPriority, + ScheduledTxStatus, + type ScheduledTxInfo, + type ScheduledTxInfoWithHandler, +} from "./useFlowSchedule" diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.test.ts b/packages/react-sdk/src/hooks/useFlowSchedule.test.ts index 2dc07ce01..46acce93e 100644 --- a/packages/react-sdk/src/hooks/useFlowSchedule.test.ts +++ b/packages/react-sdk/src/hooks/useFlowSchedule.test.ts @@ -4,9 +4,9 @@ import {createMockFclInstance, MockFclInstance} from "../__mocks__/flow-client" import {FlowProvider} from "../provider" import {useFlowChainId} from "./useFlowChainId" import { - TransactionInfoWithHandler, - TransactionPriority, - TransactionStatus, + ScheduledTxInfoWithHandler, + ScheduledTxPriority, + ScheduledTxStatus, useFlowSchedule, } from "./useFlowSchedule" @@ -29,7 +29,7 @@ describe("useFlowSchedule", () => { jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) }) - describe("list", () => { + describe("listScheduledTx", () => { test("lists transactions for an account", async () => { const mockTransactions = [ { @@ -52,12 +52,12 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const transactions = await result.current.list("0xACCOUNT") + const transactions = await result.current.listScheduledTx("0xACCOUNT") expect(transactions).toHaveLength(1) expect(transactions[0].id).toBe(1n) - expect(transactions[0].priority).toBe(TransactionPriority.Medium) - expect(transactions[0].status).toBe(TransactionStatus.Pending) + expect(transactions[0].priority).toBe(ScheduledTxPriority.Medium) + expect(transactions[0].status).toBe(ScheduledTxStatus.Pending) expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() }) @@ -85,9 +85,9 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const transactions = (await result.current.list("0xACCOUNT", { + const transactions = (await result.current.listScheduledTx("0xACCOUNT", { includeHandlerData: true, - })) as TransactionInfoWithHandler[] + })) as ScheduledTxInfoWithHandler[] expect(transactions).toHaveLength(1) expect(transactions[0].id).toBe(1n) @@ -105,7 +105,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const transactions = await result.current.list("0xACCOUNT") + const transactions = await result.current.listScheduledTx("0xACCOUNT") expect(transactions).toEqual([]) expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() @@ -121,7 +121,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - await expect(result.current.list("0xACCOUNT")).rejects.toThrow( + await expect(result.current.listScheduledTx("0xACCOUNT")).rejects.toThrow( "Chain ID not detected" ) }) @@ -134,13 +134,13 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - await expect(result.current.list("0xACCOUNT")).rejects.toThrow( + await expect(result.current.listScheduledTx("0xACCOUNT")).rejects.toThrow( "Failed to list transactions: Query failed" ) }) }) - describe("get", () => { + describe("getScheduledTx", () => { test("gets a transaction by ID", async () => { const mockTransaction = { id: "42", @@ -161,12 +161,12 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const transaction = await result.current.get(42n) + const transaction = await result.current.getScheduledTx(42n) expect(transaction).toBeDefined() expect(transaction?.id).toBe(42n) - expect(transaction?.priority).toBe(TransactionPriority.Low) - expect(transaction?.status).toBe(TransactionStatus.Completed) + expect(transaction?.priority).toBe(ScheduledTxPriority.Low) + expect(transaction?.status).toBe(ScheduledTxStatus.Completed) expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() }) @@ -192,9 +192,9 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const transaction = (await result.current.get(42n, { + const transaction = (await result.current.getScheduledTx(42n, { includeHandlerData: true, - })) as TransactionInfoWithHandler + })) as ScheduledTxInfoWithHandler expect(transaction).toBeDefined() expect(transaction.id).toBe(42n) @@ -212,7 +212,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const transaction = await result.current.get(999n) + const transaction = await result.current.getScheduledTx(999n) expect(transaction).toBeNull() expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() @@ -226,13 +226,13 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - await expect(result.current.get(42n)).rejects.toThrow( + await expect(result.current.getScheduledTx(42n)).rejects.toThrow( "Failed to get transaction: Query failed" ) }) }) - describe("setup", () => { + describe("setupScheduler", () => { test("sets up manager successfully", async () => { const txId = "setup-tx-id-123" jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) @@ -241,7 +241,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const returnedTxId = await result.current.setup() + const returnedTxId = await result.current.setupScheduler() expect(returnedTxId).toBe(txId) expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() @@ -255,13 +255,13 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - await expect(result.current.setup()).rejects.toThrow( + await expect(result.current.setupScheduler()).rejects.toThrow( "Failed to setup manager: Setup failed" ) }) }) - describe("cancel", () => { + describe("cancelScheduledTx", () => { test("cancels a transaction successfully", async () => { const txId = "cancel-tx-id-456" jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) @@ -270,7 +270,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const returnedTxId = await result.current.cancel(42n) + const returnedTxId = await result.current.cancelScheduledTx(42n) expect(returnedTxId).toBe(txId) expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() @@ -284,7 +284,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - await expect(result.current.cancel(42n)).rejects.toThrow( + await expect(result.current.cancelScheduledTx(42n)).rejects.toThrow( "Failed to cancel transaction: Cancel failed" ) }) @@ -303,7 +303,7 @@ describe("useFlowSchedule", () => { } ) - await result.current.list("0xACCOUNT") + await result.current.listScheduledTx("0xACCOUNT") expect(customFlowClient.query).toHaveBeenCalled() }) diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.ts b/packages/react-sdk/src/hooks/useFlowSchedule.ts index 59854646b..1343fb2c1 100644 --- a/packages/react-sdk/src/hooks/useFlowSchedule.ts +++ b/packages/react-sdk/src/hooks/useFlowSchedule.ts @@ -6,13 +6,13 @@ import {useFlowChainId} from "./useFlowChainId" import {CONTRACT_ADDRESSES, CADENCE_UFIX64_PRECISION} from "../constants" import {parseUnits} from "viem/utils" -export enum TransactionPriority { +export enum ScheduledTxPriority { Low = 0, Medium = 1, High = 2, } -export enum TransactionStatus { +export enum ScheduledTxStatus { Pending = 0, Processing = 1, Completed = 2, @@ -20,11 +20,11 @@ export enum TransactionStatus { Cancelled = 4, } -export interface TransactionInfo { +export interface ScheduledTxInfo { id: bigint - priority: TransactionPriority + priority: ScheduledTxPriority executionEffort: bigint - status: TransactionStatus + status: ScheduledTxStatus fees: { value: bigint formatted: string @@ -34,7 +34,7 @@ export interface TransactionInfo { handlerAddress: string } -export interface TransactionInfoWithHandler extends TransactionInfo { +export interface ScheduledTxInfoWithHandler extends ScheduledTxInfo { handlerUUID: bigint handlerResolvedViews: {[viewType: string]: any} } @@ -46,28 +46,28 @@ export interface UseFlowScheduleArgs { export interface UseFlowScheduleResult { // Lists all transactions for an account // Equivalent to: flow schedule list [--include-handler-data] - list: ( + listScheduledTx: ( account: string, options?: {includeHandlerData?: boolean} - ) => Promise + ) => Promise // Gets a transaction by ID // Equivalent to: flow schedule get [--include-handler-data] - get: ( + getScheduledTx: ( txId: bigint, options?: {includeHandlerData?: boolean} - ) => Promise + ) => Promise // Sets up a Manager resource in the signer's account if not already done // Equivalent to: flow schedule setup [--signer account] - setup: () => Promise + setupScheduler: () => Promise // Cancels a scheduled transaction by ID // Equivalent to: flow schedule cancel [--signer account] - cancel: (txId: bigint) => Promise + cancelScheduledTx: (txId: bigint) => Promise } -const listTransactionsQuery = (chainId: string) => { +const listScheduledTxQuery = (chainId: string) => { const contractAddresses = CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] if (!contractAddresses) { @@ -122,7 +122,7 @@ access(all) fun main(account: Address): [TransactionInfo] { ` } -const listTransactionsWithHandlerQuery = (chainId: string) => { +const listScheduledTxWithHandlerQuery = (chainId: string) => { const contractAddresses = CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] if (!contractAddresses) { @@ -205,7 +205,7 @@ access(all) fun main(account: Address): [TransactionInfoWithHandler] { ` } -const getTransactionQuery = (chainId: string) => { +const getScheduledTxQuery = (chainId: string) => { const contractAddresses = CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] if (!contractAddresses) { @@ -249,7 +249,7 @@ access(all) fun main(txId: UInt64): TransactionInfo? { ` } -const getTransactionWithHandlerQuery = (chainId: string) => { +const getScheduledTxWithHandlerQuery = (chainId: string) => { const contractAddresses = CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] if (!contractAddresses) { @@ -316,7 +316,7 @@ access(all) fun main(txId: UInt64): TransactionInfoWithHandler? { ` } -const setupManagerMutation = (chainId: string) => { +const setupSchedulerMutation = (chainId: string) => { const contractAddresses = CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] if (!contractAddresses) { @@ -344,7 +344,7 @@ transaction() { ` } -const cancelTransactionMutation = (chainId: string) => { +const cancelScheduledTxMutation = (chainId: string) => { const contractAddresses = CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] if (!contractAddresses) { @@ -384,12 +384,12 @@ transaction(txId: UInt64) { ` } -const convertTransactionInfo = (data: any): TransactionInfo => { +const convertScheduledTxInfo = (data: any): ScheduledTxInfo => { return { id: BigInt(data.id || 0), - priority: Number(data.priority || 0) as TransactionPriority, + priority: Number(data.priority || 0) as ScheduledTxPriority, executionEffort: BigInt(data.executionEffort || 0), - status: Number(data.status || 0) as TransactionStatus, + status: Number(data.status || 0) as ScheduledTxStatus, fees: { value: parseUnits(data.fees || "0.0", CADENCE_UFIX64_PRECISION), formatted: data.fees || "0.0", @@ -400,14 +400,14 @@ const convertTransactionInfo = (data: any): TransactionInfo => { } } -const convertTransactionInfoWithHandler = ( +const convertScheduledTxInfoWithHandler = ( data: any -): TransactionInfoWithHandler => { +): ScheduledTxInfoWithHandler => { return { id: BigInt(data.id || 0), - priority: Number(data.priority || 0) as TransactionPriority, + priority: Number(data.priority || 0) as ScheduledTxPriority, executionEffort: BigInt(data.executionEffort || 0), - status: Number(data.status || 0) as TransactionStatus, + status: Number(data.status || 0) as ScheduledTxStatus, fees: { value: parseUnits(data.fees || "0.0", CADENCE_UFIX64_PRECISION), formatted: data.fees || "0.0", @@ -436,17 +436,17 @@ export function useFlowSchedule({ const cancelMutation = useFlowMutate({flowClient}) // List function -> Lists all transactions for an account - const list = useCallback( + const listScheduledTx = useCallback( async ( account: string, options?: {includeHandlerData?: boolean} - ): Promise => { + ): Promise => { if (!chainId) throw new Error("Chain ID not detected") try { const cadence = options?.includeHandlerData - ? listTransactionsWithHandlerQuery(chainId) - : listTransactionsQuery(chainId) + ? listScheduledTxWithHandlerQuery(chainId) + : listScheduledTxQuery(chainId) const result = await fcl.query({ cadence, @@ -455,8 +455,8 @@ export function useFlowSchedule({ if (!Array.isArray(result)) return [] return options?.includeHandlerData - ? result.map(convertTransactionInfoWithHandler) - : result.map(convertTransactionInfo) + ? result.map(convertScheduledTxInfoWithHandler) + : result.map(convertScheduledTxInfo) } catch (error: any) { const message = error?.message || "Unknown error" throw new Error(`Failed to list transactions: ${message}`) @@ -466,17 +466,17 @@ export function useFlowSchedule({ ) // Get function -> Gets a specific transaction by ID - const get = useCallback( + const getScheduledTx = useCallback( async ( txId: bigint, options?: {includeHandlerData?: boolean} - ): Promise => { + ): Promise => { if (!chainId) throw new Error("Chain ID not detected") try { const cadence = options?.includeHandlerData - ? getTransactionWithHandlerQuery(chainId) - : getTransactionQuery(chainId) + ? getScheduledTxWithHandlerQuery(chainId) + : getScheduledTxQuery(chainId) const result = await fcl.query({ cadence, @@ -485,8 +485,8 @@ export function useFlowSchedule({ if (!result) return null return options?.includeHandlerData - ? convertTransactionInfoWithHandler(result) - : convertTransactionInfo(result) + ? convertScheduledTxInfoWithHandler(result) + : convertScheduledTxInfo(result) } catch (error: any) { const message = error?.message || "Unknown error" throw new Error(`Failed to get transaction: ${message}`) @@ -496,12 +496,12 @@ export function useFlowSchedule({ ) // Setup function -> Creates manager resource if not exists - const setup = useCallback(async (): Promise => { + const setupScheduler = useCallback(async (): Promise => { if (!chainId) throw new Error("Chain ID not detected") try { const result = await setupMutation.mutateAsync({ - cadence: setupManagerMutation(chainId), + cadence: setupSchedulerMutation(chainId), args: () => [], }) return result @@ -512,13 +512,13 @@ export function useFlowSchedule({ }, [setupMutation, chainId]) // Cancel function -> Cancels a scheduled transaction - const cancel = useCallback( + const cancelScheduledTx = useCallback( async (txId: bigint): Promise => { if (!chainId) throw new Error("Chain ID not detected") try { const result = await cancelMutation.mutateAsync({ - cadence: cancelTransactionMutation(chainId), + cadence: cancelScheduledTxMutation(chainId), args: () => [arg(txId.toString(), t.UInt64)], }) return result @@ -531,9 +531,9 @@ export function useFlowSchedule({ ) return { - list, - get, - setup, - cancel, + listScheduledTx, + getScheduledTx, + setupScheduler, + cancelScheduledTx, } } From c6bbe8aaab7e2a12f2230d7a9eb65f89fe70fd79 Mon Sep 17 00:00:00 2001 From: mfbz Date: Thu, 2 Oct 2025 22:58:47 +0200 Subject: [PATCH 10/19] Renamed txId to scheduledTxId for better clarity --- packages/react-sdk/src/hooks/useFlowSchedule.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.ts b/packages/react-sdk/src/hooks/useFlowSchedule.ts index 1343fb2c1..923d64ef7 100644 --- a/packages/react-sdk/src/hooks/useFlowSchedule.ts +++ b/packages/react-sdk/src/hooks/useFlowSchedule.ts @@ -54,7 +54,7 @@ export interface UseFlowScheduleResult { // Gets a transaction by ID // Equivalent to: flow schedule get [--include-handler-data] getScheduledTx: ( - txId: bigint, + scheduledTxId: bigint, options?: {includeHandlerData?: boolean} ) => Promise @@ -64,7 +64,7 @@ export interface UseFlowScheduleResult { // Cancels a scheduled transaction by ID // Equivalent to: flow schedule cancel [--signer account] - cancelScheduledTx: (txId: bigint) => Promise + cancelScheduledTx: (scheduledTxId: bigint) => Promise } const listScheduledTxQuery = (chainId: string) => { @@ -468,7 +468,7 @@ export function useFlowSchedule({ // Get function -> Gets a specific transaction by ID const getScheduledTx = useCallback( async ( - txId: bigint, + scheduledTxId: bigint, options?: {includeHandlerData?: boolean} ): Promise => { if (!chainId) throw new Error("Chain ID not detected") @@ -480,7 +480,7 @@ export function useFlowSchedule({ const result = await fcl.query({ cadence, - args: () => [arg(txId.toString(), t.UInt64)], + args: () => [arg(scheduledTxId.toString(), t.UInt64)], }) if (!result) return null @@ -513,13 +513,13 @@ export function useFlowSchedule({ // Cancel function -> Cancels a scheduled transaction const cancelScheduledTx = useCallback( - async (txId: bigint): Promise => { + async (scheduledTxId: bigint): Promise => { if (!chainId) throw new Error("Chain ID not detected") try { const result = await cancelMutation.mutateAsync({ cadence: cancelScheduledTxMutation(chainId), - args: () => [arg(txId.toString(), t.UInt64)], + args: () => [arg(scheduledTxId.toString(), t.UInt64)], }) return result } catch (error: any) { From 38620725b3d3a0d0271d007e624da313f1a9daf1 Mon Sep 17 00:00:00 2001 From: mfbz Date: Fri, 3 Oct 2025 10:58:00 +0200 Subject: [PATCH 11/19] Converted txid from bigint to string for consistency --- .../hook-cards/use-flow-schedule-card.tsx | 12 +++++----- .../src/hooks/useFlowSchedule.test.ts | 24 +++++++++---------- .../react-sdk/src/hooks/useFlowSchedule.ts | 22 ++++++++--------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx b/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx index 31f846d61..061cbcbfb 100644 --- a/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx +++ b/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx @@ -25,10 +25,10 @@ await setupScheduler() const transactions = await listScheduledTx("0xACCOUNT") // Get specific transaction -const tx = await getScheduledTx(123n) +const tx = await getScheduledTx("123") // Cancel a transaction -await cancelScheduledTx(123n)` +await cancelScheduledTx("123")` const PRIORITY_LABELS: Record = { [ScheduledTxPriority.Low]: "Low", @@ -109,7 +109,7 @@ export function UseFlowScheduleCard() { setError(null) setResult(null) try { - const transaction = await getScheduledTx(BigInt(txId), { + const transaction = await getScheduledTx(txId, { includeHandlerData, }) setResult(transaction || {message: "Transaction not found"}) @@ -130,7 +130,7 @@ export function UseFlowScheduleCard() { setError(null) setResult(null) try { - const cancelTxId = await cancelScheduledTx(BigInt(txId)) + const cancelTxId = await cancelScheduledTx(txId) setResult({ txId: cancelTxId, message: "Transaction cancelled successfully", @@ -146,7 +146,7 @@ export function UseFlowScheduleCard() { if (!tx.id) return tx return { - ID: tx.id.toString(), + ID: tx.id, Priority: PRIORITY_LABELS[tx.priority as ScheduledTxPriority] || tx.priority, Status: STATUS_LABELS[tx.status as ScheduledTxStatus] || tx.status, @@ -155,7 +155,7 @@ export function UseFlowScheduleCard() { "Scheduled At": new Date(tx.scheduledTimestamp * 1000).toLocaleString(), "Handler Type": tx.handlerTypeIdentifier, "Handler Address": tx.handlerAddress, - ...(tx.handlerUUID && {"Handler UUID": tx.handlerUUID.toString()}), + ...(tx.handlerUUID && {"Handler UUID": tx.handlerUUID}), ...(tx.handlerResolvedViews && { "Handler Views": Object.keys(tx.handlerResolvedViews).length, }), diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.test.ts b/packages/react-sdk/src/hooks/useFlowSchedule.test.ts index 46acce93e..601580422 100644 --- a/packages/react-sdk/src/hooks/useFlowSchedule.test.ts +++ b/packages/react-sdk/src/hooks/useFlowSchedule.test.ts @@ -55,7 +55,7 @@ describe("useFlowSchedule", () => { const transactions = await result.current.listScheduledTx("0xACCOUNT") expect(transactions).toHaveLength(1) - expect(transactions[0].id).toBe(1n) + expect(transactions[0].id).toBe("1") expect(transactions[0].priority).toBe(ScheduledTxPriority.Medium) expect(transactions[0].status).toBe(ScheduledTxStatus.Pending) expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() @@ -90,8 +90,8 @@ describe("useFlowSchedule", () => { })) as ScheduledTxInfoWithHandler[] expect(transactions).toHaveLength(1) - expect(transactions[0].id).toBe(1n) - expect(transactions[0].handlerUUID).toBe(9999n) + expect(transactions[0].id).toBe("1") + expect(transactions[0].handlerUUID).toBe("9999") expect(transactions[0].handlerResolvedViews).toEqual({ display: {name: "Test"}, }) @@ -161,10 +161,10 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const transaction = await result.current.getScheduledTx(42n) + const transaction = await result.current.getScheduledTx("42") expect(transaction).toBeDefined() - expect(transaction?.id).toBe(42n) + expect(transaction?.id).toBe("42") expect(transaction?.priority).toBe(ScheduledTxPriority.Low) expect(transaction?.status).toBe(ScheduledTxStatus.Completed) expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() @@ -192,13 +192,13 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const transaction = (await result.current.getScheduledTx(42n, { + const transaction = (await result.current.getScheduledTx("42", { includeHandlerData: true, })) as ScheduledTxInfoWithHandler expect(transaction).toBeDefined() - expect(transaction.id).toBe(42n) - expect(transaction.handlerUUID).toBe(5555n) + expect(transaction.id).toBe("42") + expect(transaction.handlerUUID).toBe("5555") expect(transaction.handlerResolvedViews).toEqual({ metadata: {description: "Test handler"}, }) @@ -212,7 +212,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const transaction = await result.current.getScheduledTx(999n) + const transaction = await result.current.getScheduledTx("999") expect(transaction).toBeNull() expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() @@ -226,7 +226,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - await expect(result.current.getScheduledTx(42n)).rejects.toThrow( + await expect(result.current.getScheduledTx("42")).rejects.toThrow( "Failed to get transaction: Query failed" ) }) @@ -270,7 +270,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - const returnedTxId = await result.current.cancelScheduledTx(42n) + const returnedTxId = await result.current.cancelScheduledTx("42") expect(returnedTxId).toBe(txId) expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() @@ -284,7 +284,7 @@ describe("useFlowSchedule", () => { wrapper: FlowProvider, }) - await expect(result.current.cancelScheduledTx(42n)).rejects.toThrow( + await expect(result.current.cancelScheduledTx("42")).rejects.toThrow( "Failed to cancel transaction: Cancel failed" ) }) diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.ts b/packages/react-sdk/src/hooks/useFlowSchedule.ts index 923d64ef7..cd3dc3f49 100644 --- a/packages/react-sdk/src/hooks/useFlowSchedule.ts +++ b/packages/react-sdk/src/hooks/useFlowSchedule.ts @@ -21,7 +21,7 @@ export enum ScheduledTxStatus { } export interface ScheduledTxInfo { - id: bigint + id: string priority: ScheduledTxPriority executionEffort: bigint status: ScheduledTxStatus @@ -35,7 +35,7 @@ export interface ScheduledTxInfo { } export interface ScheduledTxInfoWithHandler extends ScheduledTxInfo { - handlerUUID: bigint + handlerUUID: string handlerResolvedViews: {[viewType: string]: any} } @@ -54,7 +54,7 @@ export interface UseFlowScheduleResult { // Gets a transaction by ID // Equivalent to: flow schedule get [--include-handler-data] getScheduledTx: ( - scheduledTxId: bigint, + scheduledTxId: string, options?: {includeHandlerData?: boolean} ) => Promise @@ -64,7 +64,7 @@ export interface UseFlowScheduleResult { // Cancels a scheduled transaction by ID // Equivalent to: flow schedule cancel [--signer account] - cancelScheduledTx: (scheduledTxId: bigint) => Promise + cancelScheduledTx: (scheduledTxId: string) => Promise } const listScheduledTxQuery = (chainId: string) => { @@ -386,7 +386,7 @@ transaction(txId: UInt64) { const convertScheduledTxInfo = (data: any): ScheduledTxInfo => { return { - id: BigInt(data.id || 0), + id: data.id, priority: Number(data.priority || 0) as ScheduledTxPriority, executionEffort: BigInt(data.executionEffort || 0), status: Number(data.status || 0) as ScheduledTxStatus, @@ -404,7 +404,7 @@ const convertScheduledTxInfoWithHandler = ( data: any ): ScheduledTxInfoWithHandler => { return { - id: BigInt(data.id || 0), + id: data.id, priority: Number(data.priority || 0) as ScheduledTxPriority, executionEffort: BigInt(data.executionEffort || 0), status: Number(data.status || 0) as ScheduledTxStatus, @@ -415,7 +415,7 @@ const convertScheduledTxInfoWithHandler = ( scheduledTimestamp: Number(data.scheduledTimestamp || 0), handlerTypeIdentifier: data.handlerTypeIdentifier || "", handlerAddress: data.handlerAddress || "", - handlerUUID: BigInt(data.handlerUUID || 0), + handlerUUID: data.handlerUUID, handlerResolvedViews: data.handlerResolvedViews || {}, } } @@ -468,7 +468,7 @@ export function useFlowSchedule({ // Get function -> Gets a specific transaction by ID const getScheduledTx = useCallback( async ( - scheduledTxId: bigint, + scheduledTxId: string, options?: {includeHandlerData?: boolean} ): Promise => { if (!chainId) throw new Error("Chain ID not detected") @@ -480,7 +480,7 @@ export function useFlowSchedule({ const result = await fcl.query({ cadence, - args: () => [arg(scheduledTxId.toString(), t.UInt64)], + args: () => [arg(scheduledTxId, t.UInt64)], }) if (!result) return null @@ -513,13 +513,13 @@ export function useFlowSchedule({ // Cancel function -> Cancels a scheduled transaction const cancelScheduledTx = useCallback( - async (scheduledTxId: bigint): Promise => { + async (scheduledTxId: string): Promise => { if (!chainId) throw new Error("Chain ID not detected") try { const result = await cancelMutation.mutateAsync({ cadence: cancelScheduledTxMutation(chainId), - args: () => [arg(scheduledTxId.toString(), t.UInt64)], + args: () => [arg(scheduledTxId, t.UInt64)], }) return result } catch (error: any) { From b296b19ab31b2792485fb65d1c1a0d89455aa553 Mon Sep 17 00:00:00 2001 From: mfbz Date: Tue, 14 Oct 2025 16:58:21 +0200 Subject: [PATCH 12/19] Minor ordering update --- packages/demo/src/components/content-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/demo/src/components/content-section.tsx b/packages/demo/src/components/content-section.tsx index 9177277ba..7dce76ec6 100644 --- a/packages/demo/src/components/content-section.tsx +++ b/packages/demo/src/components/content-section.tsx @@ -87,8 +87,8 @@ export function ContentSection() { - +
From 0fedcd8c17cc962e839ebf5d336e00702b692620 Mon Sep 17 00:00:00 2001 From: mfbz Date: Tue, 14 Oct 2025 16:58:55 +0200 Subject: [PATCH 13/19] Updated card id --- .../demo/src/components/hook-cards/use-flow-schedule-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx b/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx index 061cbcbfb..38e8c206c 100644 --- a/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx +++ b/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx @@ -164,7 +164,7 @@ export function UseFlowScheduleCard() { return ( Date: Wed, 15 Oct 2025 00:12:31 +0200 Subject: [PATCH 14/19] Refactored single hook into multiple ones --- packages/react-sdk/src/hooks/index.ts | 16 +- .../src/hooks/useFlowSchedule.test.ts | 310 ---------- .../react-sdk/src/hooks/useFlowSchedule.ts | 539 ------------------ .../hooks/useFlowScheduledTransaction.test.ts | 206 +++++++ .../src/hooks/useFlowScheduledTransaction.ts | 223 ++++++++ .../useFlowScheduledTransactionCancel.test.ts | 172 ++++++ .../useFlowScheduledTransactionCancel.ts | 134 +++++ .../useFlowScheduledTransactionList.test.ts | 212 +++++++ .../hooks/useFlowScheduledTransactionList.ts | 258 +++++++++ .../useFlowScheduledTransactionSetup.test.ts | 152 +++++ .../hooks/useFlowScheduledTransactionSetup.ts | 118 ++++ 11 files changed, 1484 insertions(+), 856 deletions(-) delete mode 100644 packages/react-sdk/src/hooks/useFlowSchedule.test.ts delete mode 100644 packages/react-sdk/src/hooks/useFlowSchedule.ts create mode 100644 packages/react-sdk/src/hooks/useFlowScheduledTransaction.test.ts create mode 100644 packages/react-sdk/src/hooks/useFlowScheduledTransaction.ts create mode 100644 packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.test.ts create mode 100644 packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.ts create mode 100644 packages/react-sdk/src/hooks/useFlowScheduledTransactionList.test.ts create mode 100644 packages/react-sdk/src/hooks/useFlowScheduledTransactionList.ts create mode 100644 packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.test.ts create mode 100644 packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts diff --git a/packages/react-sdk/src/hooks/index.ts b/packages/react-sdk/src/hooks/index.ts index 2450eb314..530c5b5b4 100644 --- a/packages/react-sdk/src/hooks/index.ts +++ b/packages/react-sdk/src/hooks/index.ts @@ -16,11 +16,13 @@ export {useFlowTransactionStatus} from "./useFlowTransactionStatus" export {useCrossVmSpendNft} from "./useCrossVmSpendNft" export {useCrossVmSpendToken} from "./useCrossVmSpendToken" export {useCrossVmTransactionStatus} from "./useCrossVmTransactionStatus" -export { - useFlowSchedule, - ScheduledTxPriority, - ScheduledTxStatus, - type ScheduledTxInfo, - type ScheduledTxInfoWithHandler, -} from "./useFlowSchedule" export {useFlowNftMetadata, type NftViewResult} from "./useFlowNftMetadata" +export { + useFlowScheduledTransactionList, + ScheduledTransactionPriority, + ScheduledTransactionStatus, + type ScheduledTransactionInfo, +} from "./useFlowScheduledTransactionList" +export {useFlowScheduledTransaction} from "./useFlowScheduledTransaction" +export {useFlowScheduledTransactionSetup} from "./useFlowScheduledTransactionSetup" +export {useFlowScheduledTransactionCancel} from "./useFlowScheduledTransactionCancel" diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.test.ts b/packages/react-sdk/src/hooks/useFlowSchedule.test.ts deleted file mode 100644 index 601580422..000000000 --- a/packages/react-sdk/src/hooks/useFlowSchedule.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import * as fcl from "@onflow/fcl" -import {renderHook} from "@testing-library/react" -import {createMockFclInstance, MockFclInstance} from "../__mocks__/flow-client" -import {FlowProvider} from "../provider" -import {useFlowChainId} from "./useFlowChainId" -import { - ScheduledTxInfoWithHandler, - ScheduledTxPriority, - ScheduledTxStatus, - useFlowSchedule, -} from "./useFlowSchedule" - -jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) -jest.mock("./useFlowChainId", () => ({ - useFlowChainId: jest.fn(), -})) - -describe("useFlowSchedule", () => { - let mockFcl: MockFclInstance - - beforeEach(() => { - jest.clearAllMocks() - jest.mocked(useFlowChainId).mockReturnValue({ - data: "testnet", - isLoading: false, - } as any) - - mockFcl = createMockFclInstance() - jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) - }) - - describe("listScheduledTx", () => { - test("lists transactions for an account", async () => { - const mockTransactions = [ - { - id: "1", - priority: 1, - executionEffort: "100", - status: 0, - fees: "0.001", - scheduledTimestamp: "1234567890.0", - handlerTypeIdentifier: "A.123.Handler", - handlerAddress: "0x123", - }, - ] - - jest - .mocked(mockFcl.mockFclInstance.query) - .mockResolvedValueOnce(mockTransactions) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - const transactions = await result.current.listScheduledTx("0xACCOUNT") - - expect(transactions).toHaveLength(1) - expect(transactions[0].id).toBe("1") - expect(transactions[0].priority).toBe(ScheduledTxPriority.Medium) - expect(transactions[0].status).toBe(ScheduledTxStatus.Pending) - expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() - }) - - test("lists transactions with handler data", async () => { - const mockTransactionsWithHandler = [ - { - id: "1", - priority: 2, - executionEffort: "200", - status: 1, - fees: "0.002", - scheduledTimestamp: "1234567890.0", - handlerTypeIdentifier: "A.456.Handler", - handlerAddress: "0x456", - handlerUUID: "9999", - handlerResolvedViews: {display: {name: "Test"}}, - }, - ] - - jest - .mocked(mockFcl.mockFclInstance.query) - .mockResolvedValueOnce(mockTransactionsWithHandler) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - const transactions = (await result.current.listScheduledTx("0xACCOUNT", { - includeHandlerData: true, - })) as ScheduledTxInfoWithHandler[] - - expect(transactions).toHaveLength(1) - expect(transactions[0].id).toBe("1") - expect(transactions[0].handlerUUID).toBe("9999") - expect(transactions[0].handlerResolvedViews).toEqual({ - display: {name: "Test"}, - }) - expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() - }) - - test("returns empty array when no transactions", async () => { - jest.mocked(mockFcl.mockFclInstance.query).mockResolvedValueOnce([]) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - const transactions = await result.current.listScheduledTx("0xACCOUNT") - - expect(transactions).toEqual([]) - expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() - }) - - test("throws error when chain ID not detected", async () => { - jest.mocked(useFlowChainId).mockReturnValue({ - data: null, - isLoading: false, - } as any) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - await expect(result.current.listScheduledTx("0xACCOUNT")).rejects.toThrow( - "Chain ID not detected" - ) - }) - - test("handles query errors", async () => { - const error = new Error("Query failed") - jest.mocked(mockFcl.mockFclInstance.query).mockRejectedValueOnce(error) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - await expect(result.current.listScheduledTx("0xACCOUNT")).rejects.toThrow( - "Failed to list transactions: Query failed" - ) - }) - }) - - describe("getScheduledTx", () => { - test("gets a transaction by ID", async () => { - const mockTransaction = { - id: "42", - priority: 0, - executionEffort: "50", - status: 2, - fees: "0.0005", - scheduledTimestamp: "1234567890.0", - handlerTypeIdentifier: "A.789.Handler", - handlerAddress: "0x789", - } - - jest - .mocked(mockFcl.mockFclInstance.query) - .mockResolvedValueOnce(mockTransaction) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - const transaction = await result.current.getScheduledTx("42") - - expect(transaction).toBeDefined() - expect(transaction?.id).toBe("42") - expect(transaction?.priority).toBe(ScheduledTxPriority.Low) - expect(transaction?.status).toBe(ScheduledTxStatus.Completed) - expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() - }) - - test("gets a transaction with handler data", async () => { - const mockTransactionWithHandler = { - id: "42", - priority: 1, - executionEffort: "100", - status: 0, - fees: "0.001", - scheduledTimestamp: "1234567890.0", - handlerTypeIdentifier: "A.789.Handler", - handlerAddress: "0x789", - handlerUUID: "5555", - handlerResolvedViews: {metadata: {description: "Test handler"}}, - } - - jest - .mocked(mockFcl.mockFclInstance.query) - .mockResolvedValueOnce(mockTransactionWithHandler) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - const transaction = (await result.current.getScheduledTx("42", { - includeHandlerData: true, - })) as ScheduledTxInfoWithHandler - - expect(transaction).toBeDefined() - expect(transaction.id).toBe("42") - expect(transaction.handlerUUID).toBe("5555") - expect(transaction.handlerResolvedViews).toEqual({ - metadata: {description: "Test handler"}, - }) - expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() - }) - - test("returns null when transaction not found", async () => { - jest.mocked(mockFcl.mockFclInstance.query).mockResolvedValueOnce(null) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - const transaction = await result.current.getScheduledTx("999") - - expect(transaction).toBeNull() - expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() - }) - - test("handles query errors", async () => { - const error = new Error("Query failed") - jest.mocked(mockFcl.mockFclInstance.query).mockRejectedValueOnce(error) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - await expect(result.current.getScheduledTx("42")).rejects.toThrow( - "Failed to get transaction: Query failed" - ) - }) - }) - - describe("setupScheduler", () => { - test("sets up manager successfully", async () => { - const txId = "setup-tx-id-123" - jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - const returnedTxId = await result.current.setupScheduler() - - expect(returnedTxId).toBe(txId) - expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() - }) - - test("handles setup errors", async () => { - const error = new Error("Setup failed") - jest.mocked(mockFcl.mockFclInstance.mutate).mockRejectedValueOnce(error) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - await expect(result.current.setupScheduler()).rejects.toThrow( - "Failed to setup manager: Setup failed" - ) - }) - }) - - describe("cancelScheduledTx", () => { - test("cancels a transaction successfully", async () => { - const txId = "cancel-tx-id-456" - jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - const returnedTxId = await result.current.cancelScheduledTx("42") - - expect(returnedTxId).toBe(txId) - expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() - }) - - test("handles cancel errors", async () => { - const error = new Error("Cancel failed") - jest.mocked(mockFcl.mockFclInstance.mutate).mockRejectedValueOnce(error) - - const {result} = renderHook(() => useFlowSchedule(), { - wrapper: FlowProvider, - }) - - await expect(result.current.cancelScheduledTx("42")).rejects.toThrow( - "Failed to cancel transaction: Cancel failed" - ) - }) - }) - - test("uses custom flowClient when provided", async () => { - const customMockFcl = createMockFclInstance() - const customFlowClient = customMockFcl.mockFclInstance as any - - jest.mocked(customFlowClient.query).mockResolvedValueOnce([]) - - const {result} = renderHook( - () => useFlowSchedule({flowClient: customFlowClient}), - { - wrapper: FlowProvider, - } - ) - - await result.current.listScheduledTx("0xACCOUNT") - - expect(customFlowClient.query).toHaveBeenCalled() - }) -}) diff --git a/packages/react-sdk/src/hooks/useFlowSchedule.ts b/packages/react-sdk/src/hooks/useFlowSchedule.ts deleted file mode 100644 index cd3dc3f49..000000000 --- a/packages/react-sdk/src/hooks/useFlowSchedule.ts +++ /dev/null @@ -1,539 +0,0 @@ -import {arg, t} from "@onflow/fcl" -import {useCallback} from "react" -import {useFlowClient} from "./useFlowClient" -import {useFlowMutate} from "./useFlowMutate" -import {useFlowChainId} from "./useFlowChainId" -import {CONTRACT_ADDRESSES, CADENCE_UFIX64_PRECISION} from "../constants" -import {parseUnits} from "viem/utils" - -export enum ScheduledTxPriority { - Low = 0, - Medium = 1, - High = 2, -} - -export enum ScheduledTxStatus { - Pending = 0, - Processing = 1, - Completed = 2, - Failed = 3, - Cancelled = 4, -} - -export interface ScheduledTxInfo { - id: string - priority: ScheduledTxPriority - executionEffort: bigint - status: ScheduledTxStatus - fees: { - value: bigint - formatted: string - } - scheduledTimestamp: number - handlerTypeIdentifier: string - handlerAddress: string -} - -export interface ScheduledTxInfoWithHandler extends ScheduledTxInfo { - handlerUUID: string - handlerResolvedViews: {[viewType: string]: any} -} - -export interface UseFlowScheduleArgs { - flowClient?: ReturnType -} - -export interface UseFlowScheduleResult { - // Lists all transactions for an account - // Equivalent to: flow schedule list [--include-handler-data] - listScheduledTx: ( - account: string, - options?: {includeHandlerData?: boolean} - ) => Promise - - // Gets a transaction by ID - // Equivalent to: flow schedule get [--include-handler-data] - getScheduledTx: ( - scheduledTxId: string, - options?: {includeHandlerData?: boolean} - ) => Promise - - // Sets up a Manager resource in the signer's account if not already done - // Equivalent to: flow schedule setup [--signer account] - setupScheduler: () => Promise - - // Cancels a scheduled transaction by ID - // Equivalent to: flow schedule cancel [--signer account] - cancelScheduledTx: (scheduledTxId: string) => Promise -} - -const listScheduledTxQuery = (chainId: string) => { - const contractAddresses = - CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] - if (!contractAddresses) { - throw new Error(`Unsupported chain: ${chainId}`) - } - - return ` -import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} -import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} - -access(all) struct TransactionInfo { - access(all) let id: UInt64 - access(all) let priority: UInt8 - access(all) let executionEffort: UInt64 - access(all) let status: UInt8 - access(all) let fees: UFix64 - access(all) let scheduledTimestamp: UFix64 - access(all) let handlerTypeIdentifier: String - access(all) let handlerAddress: Address - - init(data: FlowTransactionScheduler.TransactionData) { - self.id = data.id - self.priority = data.priority.rawValue - self.executionEffort = data.executionEffort - self.status = data.status.rawValue - self.fees = data.fees - self.scheduledTimestamp = data.scheduledTimestamp - self.handlerTypeIdentifier = data.handlerTypeIdentifier - self.handlerAddress = data.handlerAddress - } -} - -/// Lists all transactions for an account -/// This script is used by: flow schedule list -access(all) fun main(account: Address): [TransactionInfo] { - // Borrow the Manager - let manager = FlowTransactionSchedulerUtils.borrowManager(at: account) - ?? panic("Could not borrow Manager from account") - - let txIds = manager.getTransactionIDs() - var transactions: [TransactionInfo] = [] - - // Get transaction data through the Manager - for id in txIds { - if let txData = manager.getTransactionData(id) { - transactions.append(TransactionInfo(data: txData)) - } - } - - return transactions -} -` -} - -const listScheduledTxWithHandlerQuery = (chainId: string) => { - const contractAddresses = - CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] - if (!contractAddresses) { - throw new Error(`Unsupported chain: ${chainId}`) - } - - return ` -import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} -import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} - -/// Combined transaction info with handler data -access(all) struct TransactionInfoWithHandler { - // Transaction fields - access(all) let id: UInt64 - access(all) let priority: UInt8 - access(all) let executionEffort: UInt64 - access(all) let status: UInt8 - access(all) let fees: UFix64 - access(all) let scheduledTimestamp: UFix64 - access(all) let handlerTypeIdentifier: String - access(all) let handlerAddress: Address - - // Handler fields - access(all) let handlerUUID: UInt64 - access(all) let handlerResolvedViews: {Type: AnyStruct} - - init(data: FlowTransactionScheduler.TransactionData, handlerUUID: UInt64, resolvedViews: {Type: AnyStruct}) { - // Initialize transaction fields - self.id = data.id - self.priority = data.priority.rawValue - self.executionEffort = data.executionEffort - self.status = data.status.rawValue - self.fees = data.fees - self.scheduledTimestamp = data.scheduledTimestamp - self.handlerTypeIdentifier = data.handlerTypeIdentifier - self.handlerAddress = data.handlerAddress - - // Initialize handler fields - self.handlerUUID = handlerUUID - self.handlerResolvedViews = resolvedViews - } -} - -/// Lists all transactions for an account with handler data -/// This script is used by: flow schedule list --include-handler-data -access(all) fun main(account: Address): [TransactionInfoWithHandler] { - // Borrow the Manager - let manager = FlowTransactionSchedulerUtils.borrowManager(at: account) - ?? panic("Could not borrow Manager from account") - - let txIds = manager.getTransactionIDs() - var transactions: [TransactionInfoWithHandler] = [] - - // Get transaction data with handler views - for id in txIds { - if let txData = manager.getTransactionData(id) { - // Borrow handler to get its UUID - let handler = txData.borrowHandler() - - // Get handler views through the manager - let availableViews = manager.getHandlerViewsFromTransactionID(id) - var resolvedViews: {Type: AnyStruct} = {} - - for viewType in availableViews { - if let resolvedView = manager.resolveHandlerViewFromTransactionID(id, viewType: viewType) { - resolvedViews[viewType] = resolvedView - } - } - - transactions.append(TransactionInfoWithHandler( - data: txData, - handlerUUID: handler.uuid, - resolvedViews: resolvedViews - )) - } - } - - return transactions -} -` -} - -const getScheduledTxQuery = (chainId: string) => { - const contractAddresses = - CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] - if (!contractAddresses) { - throw new Error(`Unsupported chain: ${chainId}`) - } - - return ` -import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} - -access(all) struct TransactionInfo { - access(all) let id: UInt64 - access(all) let priority: UInt8 - access(all) let executionEffort: UInt64 - access(all) let status: UInt8 - access(all) let fees: UFix64 - access(all) let scheduledTimestamp: UFix64 - access(all) let handlerTypeIdentifier: String - access(all) let handlerAddress: Address - - init(data: FlowTransactionScheduler.TransactionData) { - self.id = data.id - self.priority = data.priority.rawValue - self.executionEffort = data.executionEffort - self.status = data.status.rawValue - self.fees = data.fees - self.scheduledTimestamp = data.scheduledTimestamp - self.handlerTypeIdentifier = data.handlerTypeIdentifier - self.handlerAddress = data.handlerAddress - } -} - -/// Gets a transaction by ID (checks globally, not manager-specific) -/// This script is used by: flow schedule get -access(all) fun main(txId: UInt64): TransactionInfo? { - // Get transaction data directly from FlowTransactionScheduler - if let txData = FlowTransactionScheduler.getTransactionData(id: txId) { - return TransactionInfo(data: txData) - } - return nil -} -` -} - -const getScheduledTxWithHandlerQuery = (chainId: string) => { - const contractAddresses = - CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] - if (!contractAddresses) { - throw new Error(`Unsupported chain: ${chainId}`) - } - - return ` -import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} - -access(all) struct TransactionInfoWithHandler { - access(all) let id: UInt64 - access(all) let priority: UInt8 - access(all) let executionEffort: UInt64 - access(all) let status: UInt8 - access(all) let fees: UFix64 - access(all) let scheduledTimestamp: UFix64 - access(all) let handlerTypeIdentifier: String - access(all) let handlerAddress: Address - - access(all) let handlerUUID: UInt64 - access(all) let handlerResolvedViews: {Type: AnyStruct} - - init(data: FlowTransactionScheduler.TransactionData, handlerUUID: UInt64, resolvedViews: {Type: AnyStruct}) { - // Initialize transaction fields - self.id = data.id - self.priority = data.priority.rawValue - self.executionEffort = data.executionEffort - self.status = data.status.rawValue - self.fees = data.fees - self.scheduledTimestamp = data.scheduledTimestamp - self.handlerTypeIdentifier = data.handlerTypeIdentifier - self.handlerAddress = data.handlerAddress - - self.handlerUUID = handlerUUID - self.handlerResolvedViews = resolvedViews - } -} - -/// Gets a transaction by ID with handler data (checks globally, not manager-specific) -/// This script is used by: flow schedule get --include-handler-data -access(all) fun main(txId: UInt64): TransactionInfoWithHandler? { - // Get transaction data directly from FlowTransactionScheduler - if let txData = FlowTransactionScheduler.getTransactionData(id: txId) { - // Borrow handler and resolve views - let handler = txData.borrowHandler() - let availableViews = handler.getViews() - var resolvedViews: {Type: AnyStruct} = {} - - for viewType in availableViews { - if let resolvedView = handler.resolveView(viewType) { - resolvedViews[viewType] = resolvedView - } - } - - return TransactionInfoWithHandler( - data: txData, - handlerUUID: handler.uuid, - resolvedViews: resolvedViews - ) - } - - return nil -} -` -} - -const setupSchedulerMutation = (chainId: string) => { - const contractAddresses = - CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] - if (!contractAddresses) { - throw new Error(`Unsupported chain: ${chainId}`) - } - - return ` -import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} - -/// Sets up a Manager resource in the signer's account if not already done -/// This transaction is used by: flow schedule setup [--signer account] -transaction() { - prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue, PublishCapability) &Account) { - // Save a manager resource to storage if not already present - if signer.storage.borrow<&AnyResource>(from: FlowTransactionSchedulerUtils.managerStoragePath) == nil { - let manager <- FlowTransactionSchedulerUtils.createManager() - signer.storage.save(<-manager, to: FlowTransactionSchedulerUtils.managerStoragePath) - } - - // Create a capability for the Manager - let managerCap = signer.capabilities.storage.issue<&{FlowTransactionSchedulerUtils.Manager}>(FlowTransactionSchedulerUtils.managerStoragePath) - signer.capabilities.publish(managerCap, at: FlowTransactionSchedulerUtils.managerPublicPath) - } -} -` -} - -const cancelScheduledTxMutation = (chainId: string) => { - const contractAddresses = - CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] - if (!contractAddresses) { - throw new Error(`Unsupported chain: ${chainId}`) - } - - return ` -import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} -import FlowToken from ${contractAddresses.FlowToken} -import FungibleToken from ${contractAddresses.FungibleToken} - -/// Cancels a scheduled transaction by ID -/// This transaction is used by: flow schedule cancel [--signer account] -transaction(txId: UInt64) { - let manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager} - let receiverRef: &{FungibleToken.Receiver} - - prepare(signer: auth(BorrowValue) &Account) { - // Borrow the Manager with Owner entitlement - self.manager = signer.storage.borrow( - from: FlowTransactionSchedulerUtils.managerStoragePath - ) ?? panic("Could not borrow Manager with Owner entitlement from account") - - // Get receiver reference from signer's account - self.receiverRef = signer.capabilities.borrow<&{FungibleToken.Receiver}>(/public/flowTokenReceiver) - ?? panic("Could not borrow receiver reference") - } - - execute { - // Cancel the transaction and receive refunded fees - let refundedFees <- self.manager.cancel(id: txId) - - // Deposit refunded fees back to the signer's vault - self.receiverRef.deposit(from: <-refundedFees) - } -} -` -} - -const convertScheduledTxInfo = (data: any): ScheduledTxInfo => { - return { - id: data.id, - priority: Number(data.priority || 0) as ScheduledTxPriority, - executionEffort: BigInt(data.executionEffort || 0), - status: Number(data.status || 0) as ScheduledTxStatus, - fees: { - value: parseUnits(data.fees || "0.0", CADENCE_UFIX64_PRECISION), - formatted: data.fees || "0.0", - }, - scheduledTimestamp: Number(data.scheduledTimestamp || 0), - handlerTypeIdentifier: data.handlerTypeIdentifier || "", - handlerAddress: data.handlerAddress || "", - } -} - -const convertScheduledTxInfoWithHandler = ( - data: any -): ScheduledTxInfoWithHandler => { - return { - id: data.id, - priority: Number(data.priority || 0) as ScheduledTxPriority, - executionEffort: BigInt(data.executionEffort || 0), - status: Number(data.status || 0) as ScheduledTxStatus, - fees: { - value: parseUnits(data.fees || "0.0", CADENCE_UFIX64_PRECISION), - formatted: data.fees || "0.0", - }, - scheduledTimestamp: Number(data.scheduledTimestamp || 0), - handlerTypeIdentifier: data.handlerTypeIdentifier || "", - handlerAddress: data.handlerAddress || "", - handlerUUID: data.handlerUUID, - handlerResolvedViews: data.handlerResolvedViews || {}, - } -} - -/** - * Hook for interacting with the Flow Transaction Scheduler, allowing users to schedule, - * manage, and cancel scheduled transactions. - * @param {UseFlowScheduleArgs} args - Optional arguments including a custom Flow client - * @returns {UseFlowScheduleResult} An object containing functions to setup, list, get, and cancel scheduled transactions - */ -export function useFlowSchedule({ - flowClient, -}: UseFlowScheduleArgs = {}): UseFlowScheduleResult { - const fcl = useFlowClient({flowClient}) - const chainIdResult = useFlowChainId() - const chainId = chainIdResult.data - const setupMutation = useFlowMutate({flowClient}) - const cancelMutation = useFlowMutate({flowClient}) - - // List function -> Lists all transactions for an account - const listScheduledTx = useCallback( - async ( - account: string, - options?: {includeHandlerData?: boolean} - ): Promise => { - if (!chainId) throw new Error("Chain ID not detected") - - try { - const cadence = options?.includeHandlerData - ? listScheduledTxWithHandlerQuery(chainId) - : listScheduledTxQuery(chainId) - - const result = await fcl.query({ - cadence, - args: () => [arg(account, t.Address)], - }) - - if (!Array.isArray(result)) return [] - return options?.includeHandlerData - ? result.map(convertScheduledTxInfoWithHandler) - : result.map(convertScheduledTxInfo) - } catch (error: any) { - const message = error?.message || "Unknown error" - throw new Error(`Failed to list transactions: ${message}`) - } - }, - [fcl, chainId] - ) - - // Get function -> Gets a specific transaction by ID - const getScheduledTx = useCallback( - async ( - scheduledTxId: string, - options?: {includeHandlerData?: boolean} - ): Promise => { - if (!chainId) throw new Error("Chain ID not detected") - - try { - const cadence = options?.includeHandlerData - ? getScheduledTxWithHandlerQuery(chainId) - : getScheduledTxQuery(chainId) - - const result = await fcl.query({ - cadence, - args: () => [arg(scheduledTxId, t.UInt64)], - }) - - if (!result) return null - return options?.includeHandlerData - ? convertScheduledTxInfoWithHandler(result) - : convertScheduledTxInfo(result) - } catch (error: any) { - const message = error?.message || "Unknown error" - throw new Error(`Failed to get transaction: ${message}`) - } - }, - [fcl, chainId] - ) - - // Setup function -> Creates manager resource if not exists - const setupScheduler = useCallback(async (): Promise => { - if (!chainId) throw new Error("Chain ID not detected") - - try { - const result = await setupMutation.mutateAsync({ - cadence: setupSchedulerMutation(chainId), - args: () => [], - }) - return result - } catch (error: any) { - const message = error?.message || "Unknown error" - throw new Error(`Failed to setup manager: ${message}`) - } - }, [setupMutation, chainId]) - - // Cancel function -> Cancels a scheduled transaction - const cancelScheduledTx = useCallback( - async (scheduledTxId: string): Promise => { - if (!chainId) throw new Error("Chain ID not detected") - - try { - const result = await cancelMutation.mutateAsync({ - cadence: cancelScheduledTxMutation(chainId), - args: () => [arg(scheduledTxId, t.UInt64)], - }) - return result - } catch (error: any) { - const message = error?.message || "Unknown error" - throw new Error(`Failed to cancel transaction: ${message}`) - } - }, - [cancelMutation, chainId] - ) - - return { - listScheduledTx, - getScheduledTx, - setupScheduler, - cancelScheduledTx, - } -} diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransaction.test.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransaction.test.ts new file mode 100644 index 000000000..269ae745c --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransaction.test.ts @@ -0,0 +1,206 @@ +import * as fcl from "@onflow/fcl" +import {renderHook, waitFor} from "@testing-library/react" +import {createMockFclInstance, MockFclInstance} from "../__mocks__/flow-client" +import {FlowProvider} from "../provider" +import {useFlowChainId} from "./useFlowChainId" +import { + ScheduledTransactionPriority, + ScheduledTransactionStatus, +} from "./useFlowScheduledTransactionList" +import {useFlowScheduledTransaction} from "./useFlowScheduledTransaction" + +jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) +jest.mock("./useFlowChainId", () => ({ + useFlowChainId: jest.fn(), +})) + +describe("useFlowScheduledTransaction", () => { + let mockFcl: MockFclInstance + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + isLoading: false, + } as any) + + mockFcl = createMockFclInstance() + jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) + }) + + test("gets a transaction by ID", async () => { + const mockTransaction = { + id: "42", + priority: 0, + executionEffort: "50", + status: 2, + fees: "0.0005", + scheduledTimestamp: "1234567890.0", + handlerTypeIdentifier: "A.789.Handler", + handlerAddress: "0x789", + } + + jest + .mocked(mockFcl.mockFclInstance.query) + .mockResolvedValueOnce(mockTransaction) + + const {result} = renderHook( + () => useFlowScheduledTransaction({scheduledTxId: "42"}), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toBeDefined() + expect(result.current.data?.id).toBe("42") + expect(result.current.data?.priority).toBe(ScheduledTransactionPriority.Low) + expect(result.current.data?.status).toBe( + ScheduledTransactionStatus.Completed + ) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("gets a transaction with handler data", async () => { + const mockTransactionWithHandler = { + id: "42", + priority: 1, + executionEffort: "100", + status: 0, + fees: "0.001", + scheduledTimestamp: "1234567890.0", + handlerTypeIdentifier: "A.789.Handler", + handlerAddress: "0x789", + handlerUUID: "5555", + handlerResolvedViews: {metadata: {description: "Test handler"}}, + } + + jest + .mocked(mockFcl.mockFclInstance.query) + .mockResolvedValueOnce(mockTransactionWithHandler) + + const {result} = renderHook( + () => + useFlowScheduledTransaction({ + scheduledTxId: "42", + includeHandlerData: true, + }), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toBeDefined() + expect(result.current.data?.id).toBe("42") + expect(result.current.data?.handlerUUID).toBe("5555") + expect(result.current.data?.handlerResolvedViews).toEqual({ + metadata: {description: "Test handler"}, + }) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("returns null when transaction not found", async () => { + jest.mocked(mockFcl.mockFclInstance.query).mockResolvedValueOnce(null) + + const {result} = renderHook( + () => useFlowScheduledTransaction({scheduledTxId: "999"}), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toBeNull() + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("is disabled when no transaction ID provided", async () => { + const {result} = renderHook(() => useFlowScheduledTransaction(), { + wrapper: FlowProvider, + }) + + await waitFor(() => expect(result.current.isPending).toBe(true)) + + expect(result.current.data).toBeUndefined() + expect(mockFcl.mockFclInstance.query).not.toHaveBeenCalled() + }) + + test("is disabled when chain ID not detected", async () => { + jest.mocked(useFlowChainId).mockReturnValue({ + data: null, + isLoading: false, + } as any) + + const {result} = renderHook( + () => useFlowScheduledTransaction({scheduledTxId: "42"}), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isPending).toBe(true)) + + expect(result.current.data).toBeUndefined() + expect(mockFcl.mockFclInstance.query).not.toHaveBeenCalled() + }) + + test("handles query errors", async () => { + const error = new Error("Query failed") + jest.mocked(mockFcl.mockFclInstance.query).mockRejectedValueOnce(error) + + const {result} = renderHook( + () => useFlowScheduledTransaction({scheduledTxId: "42"}), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(result.current.error).toEqual(error) + }) + + test("uses custom flowClient when provided", async () => { + const customMockFcl = createMockFclInstance() + const customFlowClient = customMockFcl.mockFclInstance as any + + jest.mocked(customFlowClient.query).mockResolvedValueOnce(null) + + const {result} = renderHook( + () => + useFlowScheduledTransaction({ + scheduledTxId: "42", + flowClient: customFlowClient, + }), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(customFlowClient.query).toHaveBeenCalled() + }) + + test("respects query options enabled flag", async () => { + const {result} = renderHook( + () => + useFlowScheduledTransaction({ + scheduledTxId: "42", + query: {enabled: false}, + }), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isPending).toBe(true)) + + expect(result.current.data).toBeUndefined() + expect(mockFcl.mockFclInstance.query).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransaction.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransaction.ts new file mode 100644 index 000000000..bc2324c07 --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransaction.ts @@ -0,0 +1,223 @@ +import {useQuery, UseQueryOptions, UseQueryResult} from "@tanstack/react-query" +import {useCallback} from "react" +import {parseUnits} from "viem/utils" +import {CONTRACT_ADDRESSES, CADENCE_UFIX64_PRECISION} from "../constants" +import {useFlowQueryClient} from "../provider/FlowQueryClient" +import {useFlowClient} from "./useFlowClient" +import {useFlowChainId} from "./useFlowChainId" +import { + ScheduledTransactionInfo, + ScheduledTransactionPriority, + ScheduledTransactionStatus, +} from "./useFlowScheduledTransactionList" + +export interface UseFlowScheduledTransactionArgs { + scheduledTxId?: string + includeHandlerData?: boolean + query?: Omit< + UseQueryOptions, + "queryKey" | "queryFn" + > + flowClient?: ReturnType +} + +export type UseFlowScheduledTransactionResult = UseQueryResult< + ScheduledTransactionInfo | null, + Error +> + +const getScheduledTransactionQuery = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} + +access(all) struct TransactionInfo { + access(all) let id: UInt64 + access(all) let priority: UInt8 + access(all) let executionEffort: UInt64 + access(all) let status: UInt8 + access(all) let fees: UFix64 + access(all) let scheduledTimestamp: UFix64 + access(all) let handlerTypeIdentifier: String + access(all) let handlerAddress: Address + + init(data: FlowTransactionScheduler.TransactionData) { + self.id = data.id + self.priority = data.priority.rawValue + self.executionEffort = data.executionEffort + self.status = data.status.rawValue + self.fees = data.fees + self.scheduledTimestamp = data.scheduledTimestamp + self.handlerTypeIdentifier = data.handlerTypeIdentifier + self.handlerAddress = data.handlerAddress + } +} + +/// Gets a transaction by ID (checks globally, not manager-specific) +/// This script is used by: flow schedule get +access(all) fun main(txId: UInt64): TransactionInfo? { + // Get transaction data directly from FlowTransactionScheduler + if let txData = FlowTransactionScheduler.getTransactionData(id: txId) { + return TransactionInfo(data: txData) + } + return nil +} +` +} + +const getScheduledTransactionWithHandlerQuery = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} + +access(all) struct TransactionInfoWithHandler { + access(all) let id: UInt64 + access(all) let priority: UInt8 + access(all) let executionEffort: UInt64 + access(all) let status: UInt8 + access(all) let fees: UFix64 + access(all) let scheduledTimestamp: UFix64 + access(all) let handlerTypeIdentifier: String + access(all) let handlerAddress: Address + + access(all) let handlerUUID: UInt64 + access(all) let handlerResolvedViews: {Type: AnyStruct} + + init(data: FlowTransactionScheduler.TransactionData, handlerUUID: UInt64, resolvedViews: {Type: AnyStruct}) { + // Initialize transaction fields + self.id = data.id + self.priority = data.priority.rawValue + self.executionEffort = data.executionEffort + self.status = data.status.rawValue + self.fees = data.fees + self.scheduledTimestamp = data.scheduledTimestamp + self.handlerTypeIdentifier = data.handlerTypeIdentifier + self.handlerAddress = data.handlerAddress + + self.handlerUUID = handlerUUID + self.handlerResolvedViews = resolvedViews + } +} + +/// Gets a transaction by ID with handler data (checks globally, not manager-specific) +/// This script is used by: flow schedule get --include-handler-data +access(all) fun main(txId: UInt64): TransactionInfoWithHandler? { + // Get transaction data directly from FlowTransactionScheduler + if let txData = FlowTransactionScheduler.getTransactionData(id: txId) { + // Borrow handler and resolve views + let handler = txData.borrowHandler() + let availableViews = handler.getViews() + var resolvedViews: {Type: AnyStruct} = {} + + for viewType in availableViews { + if let resolvedView = handler.resolveView(viewType) { + resolvedViews[viewType] = resolvedView + } + } + + return TransactionInfoWithHandler( + data: txData, + handlerUUID: handler.uuid, + resolvedViews: resolvedViews + ) + } + + return nil +} +` +} + +const convertScheduledTransactionInfo = ( + data: any, + includeHandlerData: boolean +): ScheduledTransactionInfo => { + return { + id: data.id, + priority: Number(data.priority || 0) as ScheduledTransactionPriority, + executionEffort: BigInt(data.executionEffort || 0), + status: Number(data.status || 0) as ScheduledTransactionStatus, + fees: { + value: parseUnits(data.fees || "0.0", CADENCE_UFIX64_PRECISION), + formatted: data.fees || "0.0", + }, + scheduledTimestamp: Number(data.scheduledTimestamp || 0), + handlerTypeIdentifier: data.handlerTypeIdentifier || "", + handlerAddress: data.handlerAddress || "", + ...(includeHandlerData && { + handlerUUID: data.handlerUUID, + handlerResolvedViews: data.handlerResolvedViews || {}, + }), + } +} + +/** + * Hook for getting a specific scheduled transaction by ID. + * Uses TanStack Query for caching and automatic refetching. + * + * @param {UseFlowScheduledTransactionArgs} args - Configuration including transaction ID and options + * @returns {UseFlowScheduledTransactionResult} Query result with scheduled transaction or null if not found + * + * @example + * // Basic usage + * const { data: transaction, isLoading } = useFlowScheduledTransaction({ + * scheduledTxId: "42" + * }) + * + * @example + * // With handler data + * const { data: transaction } = useFlowScheduledTransaction({ + * scheduledTxId: "42", + * includeHandlerData: true + * }) + */ +export function useFlowScheduledTransaction({ + scheduledTxId, + includeHandlerData = false, + query: queryOptions = {}, + flowClient, +}: UseFlowScheduledTransactionArgs = {}): UseFlowScheduledTransactionResult { + const queryClient = useFlowQueryClient() + const fcl = useFlowClient({flowClient}) + const chainIdResult = useFlowChainId() + const chainId = chainIdResult.data + + const fetchScheduledTransaction = + useCallback(async (): Promise => { + if (!chainId || !scheduledTxId) return null + + const cadence = includeHandlerData + ? getScheduledTransactionWithHandlerQuery(chainId) + : getScheduledTransactionQuery(chainId) + + const result = await fcl.query({ + cadence, + args: (arg, t) => [arg(scheduledTxId, t.UInt64)], + }) + + if (!result) return null + + return convertScheduledTransactionInfo(result, includeHandlerData) + }, [chainId, scheduledTxId, includeHandlerData]) + + return useQuery( + { + queryKey: ["flowScheduledTransaction", scheduledTxId, includeHandlerData], + queryFn: fetchScheduledTransaction, + enabled: Boolean( + chainId && scheduledTxId && (queryOptions?.enabled ?? true) + ), + ...queryOptions, + }, + queryClient + ) +} diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.test.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.test.ts new file mode 100644 index 000000000..83162c8c2 --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.test.ts @@ -0,0 +1,172 @@ +import * as fcl from "@onflow/fcl" +import {renderHook, waitFor} from "@testing-library/react" +import {createMockFclInstance, MockFclInstance} from "../__mocks__/flow-client" +import {FlowProvider} from "../provider" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowScheduledTransactionCancel} from "./useFlowScheduledTransactionCancel" + +jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) +jest.mock("./useFlowChainId", () => ({ + useFlowChainId: jest.fn(), +})) + +describe("useFlowScheduledTransactionCancel", () => { + let mockFcl: MockFclInstance + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + isLoading: false, + } as any) + + mockFcl = createMockFclInstance() + jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) + }) + + test("cancels a transaction successfully", async () => { + const txId = "cancel-tx-id-456" + jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) + + const {result} = renderHook(() => useFlowScheduledTransactionCancel(), { + wrapper: FlowProvider, + }) + + const returnedTxId = await result.current.cancelTransactionAsync("42") + + expect(returnedTxId).toBe(txId) + expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() + }) + + test("handles cancel errors", async () => { + const error = new Error("Cancel failed") + jest.mocked(mockFcl.mockFclInstance.mutate).mockRejectedValueOnce(error) + + const {result} = renderHook(() => useFlowScheduledTransactionCancel(), { + wrapper: FlowProvider, + }) + + await expect(result.current.cancelTransactionAsync("42")).rejects.toThrow( + "Cancel failed" + ) + }) + + test("throws error when chain ID not detected", async () => { + jest.mocked(useFlowChainId).mockReturnValue({ + data: null, + isLoading: false, + } as any) + + const {result} = renderHook(() => useFlowScheduledTransactionCancel(), { + wrapper: FlowProvider, + }) + + await expect(result.current.cancelTransactionAsync("42")).rejects.toThrow( + "Chain ID not detected" + ) + }) + + test("uses custom flowClient when provided", async () => { + const customMockFcl = createMockFclInstance() + const customFlowClient = customMockFcl.mockFclInstance as any + const txId = "cancel-tx-id-789" + + jest.mocked(customFlowClient.mutate).mockResolvedValueOnce(txId) + + const {result} = renderHook( + () => useFlowScheduledTransactionCancel({flowClient: customFlowClient}), + { + wrapper: FlowProvider, + } + ) + + const returnedTxId = await result.current.cancelTransactionAsync("42") + + expect(returnedTxId).toBe(txId) + expect(customFlowClient.mutate).toHaveBeenCalled() + }) + + test("calls onSuccess callback when provided", async () => { + const txId = "cancel-tx-id-abc" + const onSuccess = jest.fn() + jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) + + const {result} = renderHook( + () => + useFlowScheduledTransactionCancel({ + mutation: {onSuccess}, + }), + { + wrapper: FlowProvider, + } + ) + + await result.current.cancelTransactionAsync("42") + + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith(txId, "42", undefined) + ) + }) + + test("calls onError callback when provided", async () => { + const error = new Error("Cancel failed") + const onError = jest.fn() + jest.mocked(mockFcl.mockFclInstance.mutate).mockRejectedValueOnce(error) + + const {result} = renderHook( + () => + useFlowScheduledTransactionCancel({ + mutation: {onError}, + }), + { + wrapper: FlowProvider, + } + ) + + await expect(result.current.cancelTransactionAsync("42")).rejects.toThrow() + + await waitFor(() => expect(onError).toHaveBeenCalled()) + }) + + test("isPending is true while mutation is in progress", async () => { + const txId = "cancel-tx-id-def" + jest.mocked(mockFcl.mockFclInstance.mutate).mockImplementation( + () => + new Promise(resolve => { + setTimeout(() => resolve(txId), 100) + }) + ) + + const {result} = renderHook(() => useFlowScheduledTransactionCancel(), { + wrapper: FlowProvider, + }) + + expect(result.current.isPending).toBe(false) + + const promise = result.current.cancelTransactionAsync("42") + + await waitFor(() => expect(result.current.isPending).toBe(true)) + + await promise + + await waitFor(() => expect(result.current.isPending).toBe(false)) + }) + + test("passes correct transaction ID to mutation", async () => { + const txId = "cancel-tx-id-xyz" + const scheduledTxId = "999" + jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) + + const {result} = renderHook(() => useFlowScheduledTransactionCancel(), { + wrapper: FlowProvider, + }) + + await result.current.cancelTransactionAsync(scheduledTxId) + + expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() + // Verify the mutation was called with args function + const mutateCall = jest.mocked(mockFcl.mockFclInstance.mutate).mock + .calls[0][0] + expect(mutateCall.args).toBeInstanceOf(Function) + }) +}) diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.ts new file mode 100644 index 000000000..e25701f99 --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.ts @@ -0,0 +1,134 @@ +import { + UseMutateAsyncFunction, + UseMutateFunction, + useMutation, + UseMutationOptions, + UseMutationResult, +} from "@tanstack/react-query" +import {CONTRACT_ADDRESSES} from "../constants" +import {useFlowQueryClient} from "../provider/FlowQueryClient" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowClient} from "./useFlowClient" + +export interface UseFlowScheduledTransactionCancelArgs { + mutation?: Omit, "mutationFn"> + flowClient?: ReturnType +} + +export interface UseFlowScheduledTransactionCancelResult + extends Omit, "mutate" | "mutateAsync"> { + cancelTransaction: UseMutateFunction + cancelTransactionAsync: UseMutateAsyncFunction +} + +const cancelScheduledTransactionMutation = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} +import FlowToken from ${contractAddresses.FlowToken} +import FungibleToken from ${contractAddresses.FungibleToken} + +/// Cancels a scheduled transaction by ID +/// This transaction is used by: flow schedule cancel [--signer account] +transaction(txId: UInt64) { + let manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager} + let receiverRef: &{FungibleToken.Receiver} + + prepare(signer: auth(BorrowValue) &Account) { + // Borrow the Manager with Owner entitlement + self.manager = signer.storage.borrow( + from: FlowTransactionSchedulerUtils.managerStoragePath + ) ?? panic("Could not borrow Manager with Owner entitlement from account") + + // Get receiver reference from signer's account + self.receiverRef = signer.capabilities.borrow<&{FungibleToken.Receiver}>(/public/flowTokenReceiver) + ?? panic("Could not borrow receiver reference") + } + + execute { + // Cancel the transaction and receive refunded fees + let refundedFees <- self.manager.cancel(id: txId) + + // Deposit refunded fees back to the signer's vault + self.receiverRef.deposit(from: <-refundedFees) + } +} +` +} + +/** + * Hook for canceling a scheduled transaction. + * Uses TanStack Query mutation for transaction handling. + * + * @param {UseFlowScheduledTransactionCancelArgs} args - Optional configuration including mutation options + * @returns {UseFlowScheduledTransactionCancelResult} Mutation result with cancelTransaction/cancelTransactionAsync functions + * + * @example + * const { cancelTransactionAsync, isPending } = useFlowScheduledTransactionCancel() + * + * const handleCancel = async (scheduledTxId: string) => { + * try { + * const txId = await cancelTransactionAsync(scheduledTxId) + * console.log("Cancel transaction ID:", txId) + * } catch (error) { + * console.error("Cancel failed:", error) + * } + * } + * + * @example + * // With mutation options + * const { cancelTransaction } = useFlowScheduledTransactionCancel({ + * mutation: { + * onSuccess: (txId) => console.log("Cancel successful:", txId), + * onError: (error) => console.error("Cancel failed:", error) + * } + * }) + */ +export function useFlowScheduledTransactionCancel({ + mutation: mutationOptions = {}, + flowClient, +}: UseFlowScheduledTransactionCancelArgs = {}): UseFlowScheduledTransactionCancelResult { + const chainIdResult = useFlowChainId() + const chainId = chainIdResult.data + const cadenceTx = chainId ? cancelScheduledTransactionMutation(chainId) : null + + const queryClient = useFlowQueryClient() + const fcl = useFlowClient({flowClient}) + + const mutation = useMutation( + { + mutationFn: async (scheduledTxId: string) => { + if (!cadenceTx) { + throw new Error("Chain ID not detected") + } + + const txId = await fcl.mutate({ + cadence: cadenceTx, + args: (arg, t) => [arg(scheduledTxId, t.UInt64)], + }) + + return txId + }, + retry: false, + ...mutationOptions, + }, + queryClient + ) + + const { + mutate: cancelTransaction, + mutateAsync: cancelTransactionAsync, + ...rest + } = mutation + + return { + cancelTransaction, + cancelTransactionAsync, + ...rest, + } +} diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.test.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.test.ts new file mode 100644 index 000000000..6c1667b7b --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.test.ts @@ -0,0 +1,212 @@ +import * as fcl from "@onflow/fcl" +import {renderHook, waitFor} from "@testing-library/react" +import {createMockFclInstance, MockFclInstance} from "../__mocks__/flow-client" +import {FlowProvider} from "../provider" +import {useFlowChainId} from "./useFlowChainId" +import { + useFlowScheduledTransactionList, + ScheduledTransactionPriority, + ScheduledTransactionStatus, +} from "./useFlowScheduledTransactionList" + +jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) +jest.mock("./useFlowChainId", () => ({ + useFlowChainId: jest.fn(), +})) + +describe("useFlowScheduledTransactionList", () => { + let mockFcl: MockFclInstance + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + isLoading: false, + } as any) + + mockFcl = createMockFclInstance() + jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) + }) + + test("lists transactions for an account", async () => { + const mockTransactions = [ + { + id: "1", + priority: 1, + executionEffort: "100", + status: 0, + fees: "0.001", + scheduledTimestamp: "1234567890.0", + handlerTypeIdentifier: "A.123.Handler", + handlerAddress: "0x123", + }, + ] + + jest + .mocked(mockFcl.mockFclInstance.query) + .mockResolvedValueOnce(mockTransactions) + + const {result} = renderHook( + () => useFlowScheduledTransactionList({account: "0xACCOUNT"}), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toHaveLength(1) + expect(result.current.data?.[0].id).toBe("1") + expect(result.current.data?.[0].priority).toBe( + ScheduledTransactionPriority.Medium + ) + expect(result.current.data?.[0].status).toBe( + ScheduledTransactionStatus.Pending + ) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("lists transactions with handler data", async () => { + const mockTransactionsWithHandler = [ + { + id: "1", + priority: 2, + executionEffort: "200", + status: 1, + fees: "0.002", + scheduledTimestamp: "1234567890.0", + handlerTypeIdentifier: "A.456.Handler", + handlerAddress: "0x456", + handlerUUID: "9999", + handlerResolvedViews: {display: {name: "Test"}}, + }, + ] + + jest + .mocked(mockFcl.mockFclInstance.query) + .mockResolvedValueOnce(mockTransactionsWithHandler) + + const {result} = renderHook( + () => + useFlowScheduledTransactionList({ + account: "0xACCOUNT", + includeHandlerData: true, + }), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toHaveLength(1) + expect(result.current.data?.[0].id).toBe("1") + expect(result.current.data?.[0].handlerUUID).toBe("9999") + expect(result.current.data?.[0].handlerResolvedViews).toEqual({ + display: {name: "Test"}, + }) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("returns empty array when no transactions", async () => { + jest.mocked(mockFcl.mockFclInstance.query).mockResolvedValueOnce([]) + + const {result} = renderHook( + () => useFlowScheduledTransactionList({account: "0xACCOUNT"}), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toEqual([]) + expect(mockFcl.mockFclInstance.query).toHaveBeenCalled() + }) + + test("is disabled when no account provided", async () => { + const {result} = renderHook(() => useFlowScheduledTransactionList(), { + wrapper: FlowProvider, + }) + + await waitFor(() => expect(result.current.isPending).toBe(true)) + + expect(result.current.data).toBeUndefined() + expect(mockFcl.mockFclInstance.query).not.toHaveBeenCalled() + }) + + test("is disabled when chain ID not detected", async () => { + jest.mocked(useFlowChainId).mockReturnValue({ + data: null, + isLoading: false, + } as any) + + const {result} = renderHook( + () => useFlowScheduledTransactionList({account: "0xACCOUNT"}), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isPending).toBe(true)) + + expect(result.current.data).toBeUndefined() + expect(mockFcl.mockFclInstance.query).not.toHaveBeenCalled() + }) + + test("handles query errors", async () => { + const error = new Error("Query failed") + jest.mocked(mockFcl.mockFclInstance.query).mockRejectedValueOnce(error) + + const {result} = renderHook( + () => useFlowScheduledTransactionList({account: "0xACCOUNT"}), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(result.current.error).toEqual(error) + }) + + test("uses custom flowClient when provided", async () => { + const customMockFcl = createMockFclInstance() + const customFlowClient = customMockFcl.mockFclInstance as any + + jest.mocked(customFlowClient.query).mockResolvedValueOnce([]) + + const {result} = renderHook( + () => + useFlowScheduledTransactionList({ + account: "0xACCOUNT", + flowClient: customFlowClient, + }), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(customFlowClient.query).toHaveBeenCalled() + }) + + test("respects query options enabled flag", async () => { + const {result} = renderHook( + () => + useFlowScheduledTransactionList({ + account: "0xACCOUNT", + query: {enabled: false}, + }), + { + wrapper: FlowProvider, + } + ) + + await waitFor(() => expect(result.current.isPending).toBe(true)) + + expect(result.current.data).toBeUndefined() + expect(mockFcl.mockFclInstance.query).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.ts new file mode 100644 index 000000000..194d15d68 --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.ts @@ -0,0 +1,258 @@ +import {useQuery, UseQueryOptions, UseQueryResult} from "@tanstack/react-query" +import {useCallback} from "react" +import {parseUnits} from "viem/utils" +import {CADENCE_UFIX64_PRECISION, CONTRACT_ADDRESSES} from "../constants" +import {useFlowQueryClient} from "../provider/FlowQueryClient" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowClient} from "./useFlowClient" + +export enum ScheduledTransactionPriority { + Low = 0, + Medium = 1, + High = 2, +} + +export enum ScheduledTransactionStatus { + Pending = 0, + Processing = 1, + Completed = 2, + Failed = 3, + Cancelled = 4, +} + +export interface ScheduledTransactionInfo { + id: string + priority: ScheduledTransactionPriority + executionEffort: bigint + status: ScheduledTransactionStatus + fees: { + value: bigint + formatted: string + } + scheduledTimestamp: number + handlerTypeIdentifier: string + handlerAddress: string + handlerUUID?: string + handlerResolvedViews?: {[viewType: string]: any} +} + +export interface UseFlowScheduledTransactionListArgs { + account?: string + includeHandlerData?: boolean + query?: Omit< + UseQueryOptions, + "queryKey" | "queryFn" + > + flowClient?: ReturnType +} + +export type UseFlowScheduledTransactionListResult = UseQueryResult< + ScheduledTransactionInfo[], + Error +> + +const getListScheduledTransactionScript = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} +import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} + +access(all) struct TransactionInfo { + access(all) let id: UInt64 + access(all) let priority: UInt8 + access(all) let executionEffort: UInt64 + access(all) let status: UInt8 + access(all) let fees: UFix64 + access(all) let scheduledTimestamp: UFix64 + access(all) let handlerTypeIdentifier: String + access(all) let handlerAddress: Address + + init(data: FlowTransactionScheduler.TransactionData) { + self.id = data.id + self.priority = data.priority.rawValue + self.executionEffort = data.executionEffort + self.status = data.status.rawValue + self.fees = data.fees + self.scheduledTimestamp = data.scheduledTimestamp + self.handlerTypeIdentifier = data.handlerTypeIdentifier + self.handlerAddress = data.handlerAddress + } +} + +access(all) fun main(account: Address): [TransactionInfo] { + let manager = FlowTransactionSchedulerUtils.borrowManager(at: account) + ?? panic("Could not borrow Manager from account") + + let txIds = manager.getTransactionIDs() + var transactions: [TransactionInfo] = [] + + for id in txIds { + if let txData = manager.getTransactionData(id) { + transactions.append(TransactionInfo(data: txData)) + } + } + + return transactions +} +` +} + +const getListScheduledTransactionWithHandlerScript = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionScheduler from ${contractAddresses.FlowTransactionScheduler} +import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} + +access(all) struct TransactionInfoWithHandler { + access(all) let id: UInt64 + access(all) let priority: UInt8 + access(all) let executionEffort: UInt64 + access(all) let status: UInt8 + access(all) let fees: UFix64 + access(all) let scheduledTimestamp: UFix64 + access(all) let handlerTypeIdentifier: String + access(all) let handlerAddress: Address + access(all) let handlerUUID: UInt64 + access(all) let handlerResolvedViews: {Type: AnyStruct} + + init(data: FlowTransactionScheduler.TransactionData, handlerUUID: UInt64, resolvedViews: {Type: AnyStruct}) { + self.id = data.id + self.priority = data.priority.rawValue + self.executionEffort = data.executionEffort + self.status = data.status.rawValue + self.fees = data.fees + self.scheduledTimestamp = data.scheduledTimestamp + self.handlerTypeIdentifier = data.handlerTypeIdentifier + self.handlerAddress = data.handlerAddress + self.handlerUUID = handlerUUID + self.handlerResolvedViews = resolvedViews + } +} + +access(all) fun main(account: Address): [TransactionInfoWithHandler] { + let manager = FlowTransactionSchedulerUtils.borrowManager(at: account) + ?? panic("Could not borrow Manager from account") + + let txIds = manager.getTransactionIDs() + var transactions: [TransactionInfoWithHandler] = [] + + for id in txIds { + if let txData = manager.getTransactionData(id) { + let handler = txData.borrowHandler() + let availableViews = manager.getHandlerViewsFromTransactionID(id) + var resolvedViews: {Type: AnyStruct} = {} + + for viewType in availableViews { + if let resolvedView = manager.resolveHandlerViewFromTransactionID(id, viewType: viewType) { + resolvedViews[viewType] = resolvedView + } + } + + transactions.append(TransactionInfoWithHandler( + data: txData, + handlerUUID: handler.uuid, + resolvedViews: resolvedViews + )) + } + } + + return transactions +} +` +} + +const convertToScheduledTransactionInfo = ( + data: any, + includeHandlerData: boolean +): ScheduledTransactionInfo => { + return { + id: data.id, + priority: Number(data.priority || 0) as ScheduledTransactionPriority, + executionEffort: BigInt(data.executionEffort || 0), + status: Number(data.status || 0) as ScheduledTransactionStatus, + fees: { + value: parseUnits(data.fees || "0.0", CADENCE_UFIX64_PRECISION), + formatted: data.fees || "0.0", + }, + scheduledTimestamp: Number(data.scheduledTimestamp || 0), + handlerTypeIdentifier: data.handlerTypeIdentifier || "", + handlerAddress: data.handlerAddress || "", + ...(includeHandlerData && { + handlerUUID: data.handlerUUID, + handlerResolvedViews: data.handlerResolvedViews || {}, + }), + } +} + +/** + * Hook for listing all scheduled transactions for an account. + * Uses TanStack Query for caching and automatic refetching. + * + * @param {UseFlowScheduledTransactionListArgs} args - Configuration including account address and options + * @returns {UseFlowScheduledTransactionListResult} Query result with list of scheduled transactions + * + * @example + * // Basic usage + * const { data: transactions, isLoading } = useFlowScheduledTransactionList({ + * account: "0x1234567890abcdef" + * }) + * + * @example + * // With handler data + * const { data: transactions } = useFlowScheduledTransactionList({ + * account: "0x1234567890abcdef", + * includeHandlerData: true + * }) + */ +export function useFlowScheduledTransactionList({ + account, + includeHandlerData = false, + query: queryOptions = {}, + flowClient, +}: UseFlowScheduledTransactionListArgs = {}): UseFlowScheduledTransactionListResult { + const queryClient = useFlowQueryClient() + const fcl = useFlowClient({flowClient}) + const chainIdResult = useFlowChainId() + const chainId = chainIdResult.data + + const fetchScheduledTransactions = useCallback(async (): Promise< + ScheduledTransactionInfo[] + > => { + if (!chainId || !account) return [] + + const cadence = includeHandlerData + ? getListScheduledTransactionWithHandlerScript(chainId) + : getListScheduledTransactionScript(chainId) + + const result = await fcl.query({ + cadence, + args: (arg, t) => [arg(account, t.Address)], + }) + + if (!Array.isArray(result)) return [] + + return result.map(data => + convertToScheduledTransactionInfo(data, includeHandlerData) + ) + }, [chainId, account, includeHandlerData]) + + return useQuery( + { + queryKey: ["flowScheduledTransactionList", account, includeHandlerData], + queryFn: fetchScheduledTransactions, + enabled: Boolean(chainId && account && (queryOptions?.enabled ?? true)), + ...queryOptions, + }, + queryClient + ) +} diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.test.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.test.ts new file mode 100644 index 000000000..35eb04e30 --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.test.ts @@ -0,0 +1,152 @@ +import * as fcl from "@onflow/fcl" +import {renderHook, waitFor} from "@testing-library/react" +import {createMockFclInstance, MockFclInstance} from "../__mocks__/flow-client" +import {FlowProvider} from "../provider" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowScheduledTransactionSetup} from "./useFlowScheduledTransactionSetup" + +jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) +jest.mock("./useFlowChainId", () => ({ + useFlowChainId: jest.fn(), +})) + +describe("useFlowScheduledTransactionSetup", () => { + let mockFcl: MockFclInstance + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + isLoading: false, + } as any) + + mockFcl = createMockFclInstance() + jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) + }) + + test("sets up manager successfully", async () => { + const txId = "setup-tx-id-123" + jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) + + const {result} = renderHook(() => useFlowScheduledTransactionSetup(), { + wrapper: FlowProvider, + }) + + const returnedTxId = await result.current.setupAsync() + + expect(returnedTxId).toBe(txId) + expect(mockFcl.mockFclInstance.mutate).toHaveBeenCalled() + }) + + test("handles setup errors", async () => { + const error = new Error("Setup failed") + jest.mocked(mockFcl.mockFclInstance.mutate).mockRejectedValueOnce(error) + + const {result} = renderHook(() => useFlowScheduledTransactionSetup(), { + wrapper: FlowProvider, + }) + + await expect(result.current.setupAsync()).rejects.toThrow("Setup failed") + }) + + test("throws error when chain ID not detected", async () => { + jest.mocked(useFlowChainId).mockReturnValue({ + data: null, + isLoading: false, + } as any) + + const {result} = renderHook(() => useFlowScheduledTransactionSetup(), { + wrapper: FlowProvider, + }) + + await expect(result.current.setupAsync()).rejects.toThrow( + "Chain ID not detected" + ) + }) + + test("uses custom flowClient when provided", async () => { + const customMockFcl = createMockFclInstance() + const customFlowClient = customMockFcl.mockFclInstance as any + const txId = "setup-tx-id-456" + + jest.mocked(customFlowClient.mutate).mockResolvedValueOnce(txId) + + const {result} = renderHook( + () => useFlowScheduledTransactionSetup({flowClient: customFlowClient}), + { + wrapper: FlowProvider, + } + ) + + const returnedTxId = await result.current.setupAsync() + + expect(returnedTxId).toBe(txId) + expect(customFlowClient.mutate).toHaveBeenCalled() + }) + + test("calls onSuccess callback when provided", async () => { + const txId = "setup-tx-id-789" + const onSuccess = jest.fn() + jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValueOnce(txId) + + const {result} = renderHook( + () => + useFlowScheduledTransactionSetup({ + mutation: {onSuccess}, + }), + { + wrapper: FlowProvider, + } + ) + + await result.current.setupAsync() + + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith(txId, undefined, undefined) + ) + }) + + test("calls onError callback when provided", async () => { + const error = new Error("Setup failed") + const onError = jest.fn() + jest.mocked(mockFcl.mockFclInstance.mutate).mockRejectedValueOnce(error) + + const {result} = renderHook( + () => + useFlowScheduledTransactionSetup({ + mutation: {onError}, + }), + { + wrapper: FlowProvider, + } + ) + + await expect(result.current.setupAsync()).rejects.toThrow() + + await waitFor(() => expect(onError).toHaveBeenCalled()) + }) + + test("isPending is true while mutation is in progress", async () => { + const txId = "setup-tx-id-abc" + jest.mocked(mockFcl.mockFclInstance.mutate).mockImplementation( + () => + new Promise(resolve => { + setTimeout(() => resolve(txId), 100) + }) + ) + + const {result} = renderHook(() => useFlowScheduledTransactionSetup(), { + wrapper: FlowProvider, + }) + + expect(result.current.isPending).toBe(false) + + const promise = result.current.setupAsync() + + await waitFor(() => expect(result.current.isPending).toBe(true)) + + await promise + + await waitFor(() => expect(result.current.isPending).toBe(false)) + }) +}) diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts new file mode 100644 index 000000000..5b095e0a2 --- /dev/null +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts @@ -0,0 +1,118 @@ +import { + UseMutateAsyncFunction, + UseMutateFunction, + useMutation, + UseMutationOptions, + UseMutationResult, +} from "@tanstack/react-query" +import {CONTRACT_ADDRESSES} from "../constants" +import {useFlowClient} from "./useFlowClient" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowQueryClient} from "../provider/FlowQueryClient" + +export interface UseFlowScheduledTransactionSetupArgs { + mutation?: Omit, "mutationFn"> + flowClient?: ReturnType +} + +export interface UseFlowScheduledTransactionSetupResult + extends Omit, "mutate" | "mutateAsync"> { + setup: UseMutateFunction + setupAsync: UseMutateAsyncFunction +} + +const setupSchedulerMutation = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FlowTransactionSchedulerUtils from ${contractAddresses.FlowTransactionSchedulerUtils} + +/// Sets up a Manager resource in the signer's account if not already done +/// This transaction is used by: flow schedule setup [--signer account] +transaction() { + prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue, PublishCapability) &Account) { + // Save a manager resource to storage if not already present + if signer.storage.borrow<&AnyResource>(from: FlowTransactionSchedulerUtils.managerStoragePath) == nil { + let manager <- FlowTransactionSchedulerUtils.createManager() + signer.storage.save(<-manager, to: FlowTransactionSchedulerUtils.managerStoragePath) + } + + // Create a capability for the Manager + let managerCap = signer.capabilities.storage.issue<&{FlowTransactionSchedulerUtils.Manager}>(FlowTransactionSchedulerUtils.managerStoragePath) + signer.capabilities.publish(managerCap, at: FlowTransactionSchedulerUtils.managerPublicPath) + } +} +` +} + +/** + * Hook for setting up a Flow Transaction Scheduler Manager resource. + * Uses TanStack Query mutation for transaction handling. + * + * @param {UseFlowScheduledTransactionSetupArgs} args - Optional configuration including mutation options + * @returns {UseFlowScheduledTransactionSetupResult} Mutation result with setup/setupAsync functions + * + * @example + * const { setupAsync, isPending } = useFlowScheduledTransactionSetup() + * + * const handleSetup = async () => { + * try { + * const txId = await setupAsync() + * console.log("Setup transaction ID:", txId) + * } catch (error) { + * console.error("Setup failed:", error) + * } + * } + * + * @example + * // With mutation options + * const { setup } = useFlowScheduledTransactionSetup({ + * mutation: { + * onSuccess: (txId) => console.log("Setup successful:", txId), + * onError: (error) => console.error("Setup failed:", error) + * } + * }) + */ +export function useFlowScheduledTransactionSetup({ + mutation: mutationOptions = {}, + flowClient, +}: UseFlowScheduledTransactionSetupArgs = {}): UseFlowScheduledTransactionSetupResult { + const chainIdResult = useFlowChainId() + const chainId = chainIdResult.data + const cadenceTx = chainId ? setupSchedulerMutation(chainId) : null + + const queryClient = useFlowQueryClient() + const fcl = useFlowClient({flowClient}) + + const mutation = useMutation( + { + mutationFn: async () => { + if (!cadenceTx) { + throw new Error("Chain ID not detected") + } + + const txId = await fcl.mutate({ + cadence: cadenceTx, + args: () => [], + }) + + return txId + }, + retry: false, + ...mutationOptions, + }, + queryClient + ) + + const {mutate: setup, mutateAsync: setupAsync, ...rest} = mutation + + return { + setup, + setupAsync, + ...rest, + } +} From dfedfc2275d9b056b78f1dd9ce6c1f1cb8393f6c Mon Sep 17 00:00:00 2001 From: mfbz Date: Wed, 15 Oct 2025 11:54:31 +0200 Subject: [PATCH 15/19] Improved hooks and added card implementation in demo --- .../demo/src/components/content-section.tsx | 4 +- .../demo/src/components/content-sidebar.tsx | 4 +- ...> use-flow-scheduled-transaction-card.tsx} | 322 ++++++++++++------ packages/react-sdk/src/hooks/index.ts | 2 +- .../src/hooks/useFlowScheduledTransaction.ts | 72 ++-- .../useFlowScheduledTransactionCancel.ts | 3 +- .../hooks/useFlowScheduledTransactionList.ts | 79 +++-- .../hooks/useFlowScheduledTransactionSetup.ts | 3 +- 8 files changed, 296 insertions(+), 193 deletions(-) rename packages/demo/src/components/hook-cards/{use-flow-schedule-card.tsx => use-flow-scheduled-transaction-card.tsx} (52%) diff --git a/packages/demo/src/components/content-section.tsx b/packages/demo/src/components/content-section.tsx index 7dce76ec6..28d9870b2 100644 --- a/packages/demo/src/components/content-section.tsx +++ b/packages/demo/src/components/content-section.tsx @@ -12,7 +12,7 @@ import {UseFlowMutateCard} from "./hook-cards/use-flow-mutate-card" import {UseFlowEventsCard} from "./hook-cards/use-flow-events-card" import {UseFlowTransactionStatusCard} from "./hook-cards/use-flow-transaction-status-card" import {UseFlowRevertibleRandomCard} from "./hook-cards/use-flow-revertible-random-card" -import {UseFlowScheduleCard} from "./hook-cards/use-flow-schedule-card" +import {UseFlowScheduledTransactionCard} from "./hook-cards/use-flow-scheduled-transaction-card" import {UseFlowNftMetadataCard} from "./hook-cards/use-flow-nft-metadata-card" // Import setup cards @@ -88,7 +88,7 @@ export function ContentSection() { - +
diff --git a/packages/demo/src/components/content-sidebar.tsx b/packages/demo/src/components/content-sidebar.tsx index 13e481b45..29dff0ca4 100644 --- a/packages/demo/src/components/content-sidebar.tsx +++ b/packages/demo/src/components/content-sidebar.tsx @@ -117,8 +117,8 @@ const sidebarItems: SidebarItem[] = [ description: "Fetch NFT metadata and traits", }, { - id: "useflowschedule", - label: "Flow Schedule", + id: "useflowscheduledtransaction", + label: "Scheduled Transaction", category: "hooks", description: "Manage scheduled transactions", }, diff --git a/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx b/packages/demo/src/components/hook-cards/use-flow-scheduled-transaction-card.tsx similarity index 52% rename from packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx rename to packages/demo/src/components/hook-cards/use-flow-scheduled-transaction-card.tsx index 38e8c206c..8a6e51889 100644 --- a/packages/demo/src/components/hook-cards/use-flow-schedule-card.tsx +++ b/packages/demo/src/components/hook-cards/use-flow-scheduled-transaction-card.tsx @@ -1,54 +1,68 @@ import { useFlowCurrentUser, - useFlowSchedule, - ScheduledTxPriority, - ScheduledTxStatus, + useFlowScheduledTransactionList, + useFlowScheduledTransaction, + useFlowScheduledTransactionSetup, + useFlowScheduledTransactionCancel, + ScheduledTransactionPriority, + ScheduledTransactionStatus, + type ScheduledTransaction, } from "@onflow/react-sdk" import {useState} from "react" import {useDarkMode} from "../flow-provider-wrapper" import {DemoCard} from "../ui/demo-card" import {ResultsSection} from "../ui/results-section" -const IMPLEMENTATION_CODE = `import { useFlowSchedule } from "@onflow/react-sdk" - -const { - setupScheduler, - listScheduledTx, - getScheduledTx, - cancelScheduledTx -} = useFlowSchedule() +const IMPLEMENTATION_CODE = `import { + useFlowScheduledTransactionList, + useFlowScheduledTransaction, + useFlowScheduledTransactionSetup, + useFlowScheduledTransactionCancel +} from "@onflow/react-sdk" -// Setup manager -await setupScheduler() +// Setup the scheduler (one-time initialization) +const { setupAsync, isPending: isSettingUp } = useFlowScheduledTransactionSetup() +await setupAsync() -// List all scheduled transactions -const transactions = await listScheduledTx("0xACCOUNT") +// List all scheduled transactions for an account +const { data: transactions, isLoading } = useFlowScheduledTransactionList({ + account: "0xACCOUNT", + includeHandlerData: true // Optional: include handler details +}) -// Get specific transaction -const tx = await getScheduledTx("123") +// Get a specific scheduled transaction by ID +const { data: transaction } = useFlowScheduledTransaction({ + scheduledTxId: "42", + includeHandlerData: true +}) -// Cancel a transaction -await cancelScheduledTx("123")` +// Cancel a scheduled transaction +const { cancelTransactionAsync } = useFlowScheduledTransactionCancel() +await cancelTransactionAsync("42")` -const PRIORITY_LABELS: Record = { - [ScheduledTxPriority.Low]: "Low", - [ScheduledTxPriority.Medium]: "Medium", - [ScheduledTxPriority.High]: "High", +const PRIORITY_LABELS: Record = { + [ScheduledTransactionPriority.Low]: "Low", + [ScheduledTransactionPriority.Medium]: "Medium", + [ScheduledTransactionPriority.High]: "High", } -const STATUS_LABELS: Record = { - [ScheduledTxStatus.Pending]: "Pending", - [ScheduledTxStatus.Processing]: "Processing", - [ScheduledTxStatus.Completed]: "Completed", - [ScheduledTxStatus.Failed]: "Failed", - [ScheduledTxStatus.Cancelled]: "Cancelled", +const STATUS_LABELS: Record = { + [ScheduledTransactionStatus.Pending]: "Pending", + [ScheduledTransactionStatus.Processing]: "Processing", + [ScheduledTransactionStatus.Completed]: "Completed", + [ScheduledTransactionStatus.Failed]: "Failed", + [ScheduledTransactionStatus.Cancelled]: "Cancelled", } -export function UseFlowScheduleCard() { +export function UseFlowScheduledTransactionCard() { const {darkMode} = useDarkMode() const {user} = useFlowCurrentUser() - const {listScheduledTx, getScheduledTx, setupScheduler, cancelScheduledTx} = - useFlowSchedule() + + // Individual hooks for each operation + const {setupAsync, isPending: isSettingUp} = + useFlowScheduledTransactionSetup() + const {cancelTransactionAsync, isPending: isCancelling} = + useFlowScheduledTransactionCancel() const [activeTab, setActiveTab] = useState< "setup" | "list" | "get" | "cancel" @@ -58,65 +72,66 @@ export function UseFlowScheduleCard() { const [includeHandlerData, setIncludeHandlerData] = useState(false) const [result, setResult] = useState(null) const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) + + // Query hooks - reactive to input changes + const listQuery = useFlowScheduledTransactionList({ + account: accountAddress || user?.addr, + includeHandlerData, + query: { + enabled: activeTab === "list" && Boolean(accountAddress || user?.addr), + }, + }) + + const getQuery = useFlowScheduledTransaction({ + scheduledTxId: txId, + includeHandlerData, + query: { + enabled: activeTab === "get" && Boolean(txId), + }, + }) const handleSetup = async () => { - setLoading(true) setError(null) setResult(null) try { - const txId = await setupScheduler() + const txId = await setupAsync() setResult({txId, message: "Manager setup successfully"}) } catch (err: any) { setError(err.message || "Setup failed") - } finally { - setLoading(false) } } - const handleList = async () => { - const address = accountAddress || user?.addr + const handleList = () => { + setError(null) + setResult(null) - if (!address) { + if (!accountAddress && !user?.addr) { setError("Please connect your wallet or enter an account address") return } - setLoading(true) - setError(null) - setResult(null) - try { - const transactions = await listScheduledTx(address, {includeHandlerData}) + // Results will automatically update via listQuery + if (listQuery.data) { setResult({ - account: address, - count: transactions.length, - transactions, + account: accountAddress || user?.addr, + count: listQuery.data.length, + transactions: listQuery.data, }) - } catch (err: any) { - setError(err.message || "Failed to list transactions") - } finally { - setLoading(false) } } - const handleGet = async () => { + const handleGet = () => { + setError(null) + setResult(null) + if (!txId) { setError("Please enter a transaction ID") return } - setLoading(true) - setError(null) - setResult(null) - try { - const transaction = await getScheduledTx(txId, { - includeHandlerData, - }) - setResult(transaction || {message: "Transaction not found"}) - } catch (err: any) { - setError(err.message || "Failed to get transaction") - } finally { - setLoading(false) + // Results will automatically update via getQuery + if (getQuery.data !== undefined) { + setResult(getQuery.data || {message: "Transaction not found"}) } } @@ -126,30 +141,38 @@ export function UseFlowScheduleCard() { return } - setLoading(true) setError(null) setResult(null) try { - const cancelTxId = await cancelScheduledTx(txId) + const cancelTxId = await cancelTransactionAsync(txId) setResult({ txId: cancelTxId, message: "Transaction cancelled successfully", }) } catch (err: any) { setError(err.message || "Failed to cancel transaction") - } finally { - setLoading(false) } } - const formatTransactionInfo = (tx: any) => { + // Update results when queries complete + const handleRefresh = () => { + if (activeTab === "list") { + handleList() + } else if (activeTab === "get") { + handleGet() + } + } + + const formatTransactionInfo = (tx: ScheduledTransaction) => { if (!tx.id) return tx return { ID: tx.id, Priority: - PRIORITY_LABELS[tx.priority as ScheduledTxPriority] || tx.priority, - Status: STATUS_LABELS[tx.status as ScheduledTxStatus] || tx.status, + PRIORITY_LABELS[tx.priority as ScheduledTransactionPriority] || + tx.priority, + Status: + STATUS_LABELS[tx.status as ScheduledTransactionStatus] || tx.status, "Execution Effort": tx.executionEffort.toString(), "Fees (FLOW)": tx.fees.formatted, "Scheduled At": new Date(tx.scheduledTimestamp * 1000).toLocaleString(), @@ -162,13 +185,19 @@ export function UseFlowScheduleCard() { } } + const isLoading = + (activeTab === "list" && listQuery.isLoading) || + (activeTab === "get" && getQuery.isLoading) || + isSettingUp || + isCancelling + return (
@@ -201,18 +230,26 @@ export function UseFlowScheduleCard() { className={`text-sm ${darkMode ? "text-gray-300" : "text-gray-600"}`} > Initialize the Transaction Scheduler Manager in your account + (one-time setup)

+ {!user?.addr && ( +

+ Please connect your wallet to setup the scheduler +

+ )}
)} @@ -251,29 +288,48 @@ export function UseFlowScheduleCard() {
setIncludeHandlerData(e.target.checked)} className="w-4 h-4" />
- +
+ + {listQuery.data && ( + + )} +
)} @@ -315,17 +371,34 @@ export function UseFlowScheduleCard() { Include handler data
- +
+ + {getQuery.data && ( + + )} +
)} @@ -354,18 +427,47 @@ export function UseFlowScheduleCard() { + {!user?.addr && ( +

+ Please connect your wallet to cancel transactions +

+ )} )} + {activeTab === "list" && listQuery.data && !result && ( + + )} + + {activeTab === "get" && getQuery.data && !result && ( + + )} + {(result || error) && ( )} + + {isLoading && !result && !error && ( +
+ Loading... +
+ )} ) diff --git a/packages/react-sdk/src/hooks/index.ts b/packages/react-sdk/src/hooks/index.ts index 530c5b5b4..34129c609 100644 --- a/packages/react-sdk/src/hooks/index.ts +++ b/packages/react-sdk/src/hooks/index.ts @@ -21,7 +21,7 @@ export { useFlowScheduledTransactionList, ScheduledTransactionPriority, ScheduledTransactionStatus, - type ScheduledTransactionInfo, + type ScheduledTransaction, } from "./useFlowScheduledTransactionList" export {useFlowScheduledTransaction} from "./useFlowScheduledTransaction" export {useFlowScheduledTransactionSetup} from "./useFlowScheduledTransactionSetup" diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransaction.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransaction.ts index bc2324c07..8a9114c93 100644 --- a/packages/react-sdk/src/hooks/useFlowScheduledTransaction.ts +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransaction.ts @@ -1,12 +1,11 @@ -import {useQuery, UseQueryOptions, UseQueryResult} from "@tanstack/react-query" -import {useCallback} from "react" +import {UseQueryOptions, UseQueryResult} from "@tanstack/react-query" import {parseUnits} from "viem/utils" import {CONTRACT_ADDRESSES, CADENCE_UFIX64_PRECISION} from "../constants" -import {useFlowQueryClient} from "../provider/FlowQueryClient" import {useFlowClient} from "./useFlowClient" import {useFlowChainId} from "./useFlowChainId" +import {useFlowQuery} from "./useFlowQuery" import { - ScheduledTransactionInfo, + ScheduledTransaction, ScheduledTransactionPriority, ScheduledTransactionStatus, } from "./useFlowScheduledTransactionList" @@ -15,14 +14,14 @@ export interface UseFlowScheduledTransactionArgs { scheduledTxId?: string includeHandlerData?: boolean query?: Omit< - UseQueryOptions, + UseQueryOptions, "queryKey" | "queryFn" > flowClient?: ReturnType } export type UseFlowScheduledTransactionResult = UseQueryResult< - ScheduledTransactionInfo | null, + ScheduledTransaction | null, Error > @@ -137,10 +136,10 @@ access(all) fun main(txId: UInt64): TransactionInfoWithHandler? { ` } -const convertScheduledTransactionInfo = ( +const convertScheduledTransaction = ( data: any, includeHandlerData: boolean -): ScheduledTransactionInfo => { +): ScheduledTransaction => { return { id: data.id, priority: Number(data.priority || 0) as ScheduledTransactionPriority, @@ -162,9 +161,8 @@ const convertScheduledTransactionInfo = ( /** * Hook for getting a specific scheduled transaction by ID. - * Uses TanStack Query for caching and automatic refetching. * - * @param {UseFlowScheduledTransactionArgs} args - Configuration including transaction ID and options + * @param {UseFlowScheduledTransactionArgs} args Configuration including transaction ID and options * @returns {UseFlowScheduledTransactionResult} Query result with scheduled transaction or null if not found * * @example @@ -186,38 +184,38 @@ export function useFlowScheduledTransaction({ query: queryOptions = {}, flowClient, }: UseFlowScheduledTransactionArgs = {}): UseFlowScheduledTransactionResult { - const queryClient = useFlowQueryClient() - const fcl = useFlowClient({flowClient}) const chainIdResult = useFlowChainId() const chainId = chainIdResult.data - const fetchScheduledTransaction = - useCallback(async (): Promise => { - if (!chainId || !scheduledTxId) return null - - const cadence = includeHandlerData + const queryResult = useFlowQuery({ + cadence: chainId + ? includeHandlerData ? getScheduledTransactionWithHandlerQuery(chainId) : getScheduledTransactionQuery(chainId) + : "", + args: scheduledTxId + ? (arg, t) => [arg(scheduledTxId, t.UInt64)] + : undefined, + query: { + ...(queryOptions as Omit< + UseQueryOptions, + "queryKey" | "queryFn" + >), + enabled: (queryOptions?.enabled ?? true) && !!chainId && !!scheduledTxId, + }, + flowClient, + }) - const result = await fcl.query({ - cadence, - args: (arg, t) => [arg(scheduledTxId, t.UInt64)], - }) - - if (!result) return null - - return convertScheduledTransactionInfo(result, includeHandlerData) - }, [chainId, scheduledTxId, includeHandlerData]) + // Transform raw Cadence data to ScheduledTransaction or null + const data = + queryResult.data !== undefined + ? queryResult.data + ? convertScheduledTransaction(queryResult.data, includeHandlerData) + : null + : undefined - return useQuery( - { - queryKey: ["flowScheduledTransaction", scheduledTxId, includeHandlerData], - queryFn: fetchScheduledTransaction, - enabled: Boolean( - chainId && scheduledTxId && (queryOptions?.enabled ?? true) - ), - ...queryOptions, - }, - queryClient - ) + return { + ...queryResult, + data, + } as UseFlowScheduledTransactionResult } diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.ts index e25701f99..dcec80999 100644 --- a/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.ts +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionCancel.ts @@ -63,9 +63,8 @@ transaction(txId: UInt64) { /** * Hook for canceling a scheduled transaction. - * Uses TanStack Query mutation for transaction handling. * - * @param {UseFlowScheduledTransactionCancelArgs} args - Optional configuration including mutation options + * @param {UseFlowScheduledTransactionCancelArgs} args Optional configuration including mutation options * @returns {UseFlowScheduledTransactionCancelResult} Mutation result with cancelTransaction/cancelTransactionAsync functions * * @example diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.ts index 194d15d68..4918d5ab9 100644 --- a/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.ts +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionList.ts @@ -1,10 +1,9 @@ -import {useQuery, UseQueryOptions, UseQueryResult} from "@tanstack/react-query" -import {useCallback} from "react" +import {UseQueryOptions, UseQueryResult} from "@tanstack/react-query" import {parseUnits} from "viem/utils" import {CADENCE_UFIX64_PRECISION, CONTRACT_ADDRESSES} from "../constants" -import {useFlowQueryClient} from "../provider/FlowQueryClient" import {useFlowChainId} from "./useFlowChainId" import {useFlowClient} from "./useFlowClient" +import {useFlowQuery} from "./useFlowQuery" export enum ScheduledTransactionPriority { Low = 0, @@ -20,7 +19,7 @@ export enum ScheduledTransactionStatus { Cancelled = 4, } -export interface ScheduledTransactionInfo { +export interface ScheduledTransaction { id: string priority: ScheduledTransactionPriority executionEffort: bigint @@ -40,14 +39,14 @@ export interface UseFlowScheduledTransactionListArgs { account?: string includeHandlerData?: boolean query?: Omit< - UseQueryOptions, + UseQueryOptions, "queryKey" | "queryFn" > flowClient?: ReturnType } export type UseFlowScheduledTransactionListResult = UseQueryResult< - ScheduledTransactionInfo[], + ScheduledTransaction[], Error > @@ -171,10 +170,10 @@ access(all) fun main(account: Address): [TransactionInfoWithHandler] { ` } -const convertToScheduledTransactionInfo = ( +const convertToScheduledTransaction = ( data: any, includeHandlerData: boolean -): ScheduledTransactionInfo => { +): ScheduledTransaction => { return { id: data.id, priority: Number(data.priority || 0) as ScheduledTransactionPriority, @@ -196,9 +195,8 @@ const convertToScheduledTransactionInfo = ( /** * Hook for listing all scheduled transactions for an account. - * Uses TanStack Query for caching and automatic refetching. * - * @param {UseFlowScheduledTransactionListArgs} args - Configuration including account address and options + * @param {UseFlowScheduledTransactionListArgs} args Configuration including account address and options * @returns {UseFlowScheduledTransactionListResult} Query result with list of scheduled transactions * * @example @@ -220,39 +218,38 @@ export function useFlowScheduledTransactionList({ query: queryOptions = {}, flowClient, }: UseFlowScheduledTransactionListArgs = {}): UseFlowScheduledTransactionListResult { - const queryClient = useFlowQueryClient() - const fcl = useFlowClient({flowClient}) const chainIdResult = useFlowChainId() const chainId = chainIdResult.data - const fetchScheduledTransactions = useCallback(async (): Promise< - ScheduledTransactionInfo[] - > => { - if (!chainId || !account) return [] - - const cadence = includeHandlerData - ? getListScheduledTransactionWithHandlerScript(chainId) - : getListScheduledTransactionScript(chainId) - - const result = await fcl.query({ - cadence, - args: (arg, t) => [arg(account, t.Address)], - }) - - if (!Array.isArray(result)) return [] - - return result.map(data => - convertToScheduledTransactionInfo(data, includeHandlerData) - ) - }, [chainId, account, includeHandlerData]) - - return useQuery( - { - queryKey: ["flowScheduledTransactionList", account, includeHandlerData], - queryFn: fetchScheduledTransactions, - enabled: Boolean(chainId && account && (queryOptions?.enabled ?? true)), - ...queryOptions, + const queryResult = useFlowQuery({ + cadence: chainId + ? includeHandlerData + ? getListScheduledTransactionWithHandlerScript(chainId) + : getListScheduledTransactionScript(chainId) + : "", + args: account ? (arg, t) => [arg(account, t.Address)] : undefined, + query: { + ...(queryOptions as Omit< + UseQueryOptions, + "queryKey" | "queryFn" + >), + enabled: (queryOptions?.enabled ?? true) && !!chainId && !!account, }, - queryClient - ) + flowClient, + }) + + // Transform raw Cadence data to ScheduledTransaction[] + const data = + queryResult.data !== undefined + ? Array.isArray(queryResult.data) + ? queryResult.data.map(item => + convertToScheduledTransaction(item, includeHandlerData) + ) + : [] + : undefined + + return { + ...queryResult, + data, + } as UseFlowScheduledTransactionListResult } diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts index 5b095e0a2..aff766677 100644 --- a/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts @@ -51,9 +51,8 @@ transaction() { /** * Hook for setting up a Flow Transaction Scheduler Manager resource. - * Uses TanStack Query mutation for transaction handling. * - * @param {UseFlowScheduledTransactionSetupArgs} args - Optional configuration including mutation options + * @param {UseFlowScheduledTransactionSetupArgs} args Optional configuration including mutation options * @returns {UseFlowScheduledTransactionSetupResult} Mutation result with setup/setupAsync functions * * @example From 96bf4bad9eac9ebe03c6f5f78ab833eb30d18b75 Mon Sep 17 00:00:00 2001 From: mfbz Date: Wed, 15 Oct 2025 16:29:30 +0200 Subject: [PATCH 16/19] Made setup transaction idempodent --- .../src/hooks/useFlowScheduledTransactionSetup.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts b/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts index aff766677..3d16c93d5 100644 --- a/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts +++ b/packages/react-sdk/src/hooks/useFlowScheduledTransactionSetup.ts @@ -41,9 +41,18 @@ transaction() { signer.storage.save(<-manager, to: FlowTransactionSchedulerUtils.managerStoragePath) } - // Create a capability for the Manager - let managerCap = signer.capabilities.storage.issue<&{FlowTransactionSchedulerUtils.Manager}>(FlowTransactionSchedulerUtils.managerStoragePath) - signer.capabilities.publish(managerCap, at: FlowTransactionSchedulerUtils.managerPublicPath) + // Check if capability is already published + let existingCap = signer.capabilities.get<&{FlowTransactionSchedulerUtils.Manager}>( + FlowTransactionSchedulerUtils.managerPublicPath + ) + // Only issue and publish if not already published or invalid + if !existingCap.check() { + // Create a new capability for the Manager + let managerCap = signer.capabilities.storage.issue<&{FlowTransactionSchedulerUtils.Manager}>( + FlowTransactionSchedulerUtils.managerStoragePath + ) + signer.capabilities.publish(managerCap, at: FlowTransactionSchedulerUtils.managerPublicPath) + } } } ` From 567e80ad36f8e90dac87c1d8753720af54e475ef Mon Sep 17 00:00:00 2001 From: Michael Fabozzi <39808567+mfbz@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:42:49 +0200 Subject: [PATCH 17/19] Update packages/demo/src/components/content-sidebar.tsx Co-authored-by: Chase Fleming --- packages/demo/src/components/content-sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/demo/src/components/content-sidebar.tsx b/packages/demo/src/components/content-sidebar.tsx index 29dff0ca4..1511a262b 100644 --- a/packages/demo/src/components/content-sidebar.tsx +++ b/packages/demo/src/components/content-sidebar.tsx @@ -120,7 +120,7 @@ const sidebarItems: SidebarItem[] = [ id: "useflowscheduledtransaction", label: "Scheduled Transaction", category: "hooks", - description: "Manage scheduled transactions", + description: "Manage Scheduled Transactions", }, // Advanced section From 8a761cf98873b13f15006486680132a622b7d32f Mon Sep 17 00:00:00 2001 From: Michael Fabozzi <39808567+mfbz@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:43:22 +0200 Subject: [PATCH 18/19] Update packages/demo/src/components/content-sidebar.tsx Co-authored-by: Chase Fleming --- packages/demo/src/components/content-sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/demo/src/components/content-sidebar.tsx b/packages/demo/src/components/content-sidebar.tsx index 1511a262b..46b78fdce 100644 --- a/packages/demo/src/components/content-sidebar.tsx +++ b/packages/demo/src/components/content-sidebar.tsx @@ -118,7 +118,7 @@ const sidebarItems: SidebarItem[] = [ }, { id: "useflowscheduledtransaction", - label: "Scheduled Transaction", + label: "Scheduled Transactions", category: "hooks", description: "Manage Scheduled Transactions", }, From 66f39ddefc928edcbba8c66e093f6022c7a0e446 Mon Sep 17 00:00:00 2001 From: mfbz Date: Wed, 15 Oct 2025 22:23:46 +0200 Subject: [PATCH 19/19] Improved scheduled transactions demo card --- .../use-flow-scheduled-transaction-card.tsx | 102 ++++++++++++++++-- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/packages/demo/src/components/hook-cards/use-flow-scheduled-transaction-card.tsx b/packages/demo/src/components/hook-cards/use-flow-scheduled-transaction-card.tsx index 8a6e51889..f001fa5f5 100644 --- a/packages/demo/src/components/hook-cards/use-flow-scheduled-transaction-card.tsx +++ b/packages/demo/src/components/hook-cards/use-flow-scheduled-transaction-card.tsx @@ -194,10 +194,10 @@ export function UseFlowScheduledTransactionCard() { return (
@@ -226,12 +226,25 @@ export function UseFlowScheduledTransactionCard() { {activeTab === "setup" && (
-

- Initialize the Transaction Scheduler Manager in your account - (one-time setup) -

+

+ Setup Scheduler Manager +

+

+ Before scheduling transactions, you need to initialize a Manager + resource in your account. This is a one-time setup that creates + the necessary storage and capabilities for managing scheduled + transactions. The manager tracks your scheduled transactions and + handles their execution. +

+