From 2bcd63d0531218976ad2f9c815ff4f201165462b Mon Sep 17 00:00:00 2001 From: Josh Hannan Date: Mon, 27 Oct 2025 11:30:17 -0400 Subject: [PATCH] add COA handler, schedule tx, and simple test --- contracts/FlowTransactionSchedulerUtils.cdc | 171 ++++++++++++++++++ flow.json | 4 +- tests/scheduled_transaction_test_helpers.cdc | 38 ++++ tests/transactionScheduler_coa_test.cdc | 62 +++++++ tests/transactionScheduler_test.cdc | 15 ++ .../schedule_coa_transaction.cdc | 113 ++++++++++++ 6 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 tests/transactionScheduler_coa_test.cdc create mode 100644 transactions/transactionScheduler/schedule_coa_transaction.cdc diff --git a/contracts/FlowTransactionSchedulerUtils.cdc b/contracts/FlowTransactionSchedulerUtils.cdc index d6eadb65..f0fd9956 100644 --- a/contracts/FlowTransactionSchedulerUtils.cdc +++ b/contracts/FlowTransactionSchedulerUtils.cdc @@ -1,5 +1,8 @@ import "FlowTransactionScheduler" +import "FungibleToken" import "FlowToken" +import "EVM" +import "MetadataViews" /// FlowTransactionSchedulerUtils provides utility functionality for working with scheduled transactions /// on the Flow blockchain. Currently, it only includes a Manager resource for managing scheduled transactions. @@ -561,6 +564,174 @@ access(all) contract FlowTransactionSchedulerUtils { return getAccount(at).capabilities.borrow<&{Manager}>(self.managerPublicPath) } + /********************************************* + + COA Handler Utils + + **********************************************/ + + access(all) view fun coaHandlerStoragePath(): StoragePath { + return /storage/coaScheduledTransactionHandler + } + + access(all) view fun coaHandlerPublicPath(): PublicPath { + return /public/coaScheduledTransactionHandler + } + + /// COATransactionHandler is a resource that wraps a capability to a COA (Contract Owned Account) + /// and implements the TransactionHandler interface to allow scheduling transactions for COAs. + /// This handler enables users to schedule transactions that will be executed on behalf of their COA. + access(all) resource COATransactionHandler: FlowTransactionScheduler.TransactionHandler { + /// The capability to the COA resource + access(self) let coaCapability: Capability + + /// The capability to the FlowToken vault + access(self) let flowTokenVaultCapability: Capability + + init(coaCapability: Capability, + flowTokenVaultCapability: Capability, + ) + { + self.coaCapability = coaCapability + self.flowTokenVaultCapability = flowTokenVaultCapability + } + + /// Execute the scheduled transaction using the COA + /// @param id: The ID of the scheduled transaction + /// @param data: Optional data passed to the transaction execution. In this case, the data needs to be a COAHandlerParams struct with valid values. + access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { + let coa = self.coaCapability.borrow() + ?? panic("COA capability is invalid or expired for scheduled transaction with ID \(id)") + + if let params = data as? COAHandlerParams { + switch params.txType { + case COAHandlerTxType.DepositFLOW: + if params.amount == nil { + panic("Amount is required for deposit for scheduled transaction with ID \(id)") + } + let vault = self.flowTokenVaultCapability.borrow() + ?? panic("FlowToken vault capability is invalid or expired for scheduled transaction with ID \(id)") + coa.deposit(from: <-vault.withdraw(amount: params.amount!) as! @FlowToken.Vault) + case COAHandlerTxType.WithdrawFLOW: + if params.amount == nil { + panic("Amount is required for withdrawal from COA for scheduled transaction with ID \(id)") + } + let vault = self.flowTokenVaultCapability.borrow() + ?? panic("FlowToken vault capability is invalid or expired for scheduled transaction with ID \(id)") + let amount = EVM.Balance(attoflow: 0) + amount.setFLOW(flow: params.amount!) + vault.deposit(from: <-coa.withdraw(balance: amount) as! @{FungibleToken.Vault}) + case COAHandlerTxType.Call: + if params.callToEVMAddress == nil || params.data == nil || params.gasLimit == nil || params.value == nil { + panic("Call to EVM address, data, gas limit, and value are required for EVM call for scheduled transaction with ID \(id)") + } + let result = coa.call(to: params.callToEVMAddress!, data: params.data!, gasLimit: params.gasLimit!, value: params.value!) + } + } else { + panic("Invalid scheduled transactiondata type for COA handler execution for tx with ID \(id)! Expected FlowTransactionSchedulerUtils.COAHandlerParams but got \(data.getType().identifier)") + } + } + + /// Get the views supported by this handler + /// @return: Array of view types + access(all) view fun getViews(): [Type] { + return [ + Type(), + Type(), + Type(), + Type() + ] + } + + /// Resolve a view for this handler + /// @param viewType: The type of view to resolve + /// @return: The resolved view data, or nil if not supported + access(all) fun resolveView(_ viewType: Type): AnyStruct? { + if viewType == Type() { + return COAHandlerView( + coaOwner: self.coaCapability.borrow()?.owner?.address, + coaEVMAddress: self.coaCapability.borrow()?.address(), + ) + } + if viewType == Type() { + return FlowTransactionSchedulerUtils.coaHandlerStoragePath() + } else if viewType == Type() { + return FlowTransactionSchedulerUtils.coaHandlerPublicPath() + } else if viewType == Type() { + return MetadataViews.Display( + name: "COA Scheduled Transaction Handler", + description: "Scheduled Transaction Handler that can execute transactions on behalf of a COA", + thumbnail: MetadataViews.HTTPFile( + url: "" + ) + ) + } + return nil + } + } + + /// Enum for COA handler execution type + access(all) enum COAHandlerTxType: UInt8 { + access(all) case DepositFLOW + access(all) case WithdrawFLOW + access(all) case Call + + // TODO: Should we have other transaction types?? + } + + access(all) struct COAHandlerParams { + + access(all) let txType: COAHandlerTxType + + access(all) let amount: UFix64? + access(all) let callToEVMAddress: EVM.EVMAddress? + access(all) let data: [UInt8]? + access(all) let gasLimit: UInt64? + access(all) let value: EVM.Balance? + + init(txType: UInt8, amount: UFix64?, callToEVMAddress: [UInt8; 20]?, data: [UInt8]?, gasLimit: UInt64?, value: UFix64?) { + self.txType = COAHandlerTxType(rawValue: txType) + ?? panic("Invalid COA transaction type enum") + self.amount = amount + self.callToEVMAddress = callToEVMAddress != nil ? EVM.EVMAddress(bytes: callToEVMAddress!) : nil + self.data = data + self.gasLimit = gasLimit + if let unwrappedValue = value { + self.value = EVM.Balance(attoflow: 0) + self.value!.setFLOW(flow: unwrappedValue) + } else { + self.value = nil + } + } + } + + /// View struct for COA handler metadata + access(all) struct COAHandlerView { + access(all) let coaOwner: Address? + access(all) let coaEVMAddress: EVM.EVMAddress? + + // TODO: Should we include other metadata about the COA, like balance, code, etc??? + + init(coaOwner: Address?, coaEVMAddress: EVM.EVMAddress?) { + self.coaOwner = coaOwner + self.coaEVMAddress = coaEVMAddress + } + } + + /// Create a COA transaction handler + /// @param coaCapability: Capability to the COA resource + /// @param metadata: Optional metadata about the handler + /// @return: A new COATransactionHandler resource + access(all) fun createCOATransactionHandler( + coaCapability: Capability, + flowTokenVaultCapability: Capability, + ): @COATransactionHandler { + return <-create COATransactionHandler( + coaCapability: coaCapability, + flowTokenVaultCapability: flowTokenVaultCapability, + ) + } + /******************************************** Scheduled Transactions Metadata Views diff --git a/flow.json b/flow.json index 71dfee66..375f11cf 100644 --- a/flow.json +++ b/flow.json @@ -12,6 +12,7 @@ "source": "./contracts/FlowTransactionScheduler.cdc", "aliases": { "emulator": "f8d6e0586b0a20c7", + "testnet": "8c5303eaa26202d6", "testing": "0000000000000007" } }, @@ -223,7 +224,8 @@ "emulator": "127.0.0.1:3569", "mainnet": "access.mainnet.nodes.onflow.org:9000", "testing": "127.0.0.1:3569", - "testnet": "access.devnet.nodes.onflow.org:9000" + "testnet": "access.devnet.nodes.onflow.org:9000", + "migration": "access-001.migrationtestnet1.nodes.onflow.org:9000" }, "accounts": { "emulator-account": { diff --git a/tests/scheduled_transaction_test_helpers.cdc b/tests/scheduled_transaction_test_helpers.cdc index 018c375e..b3f9e57f 100644 --- a/tests/scheduled_transaction_test_helpers.cdc +++ b/tests/scheduled_transaction_test_helpers.cdc @@ -119,6 +119,44 @@ access(all) fun scheduleTransactionByHandler( } } +access(all) fun scheduleCOATransaction( + timestamp: UFix64, + fee: UFix64, + effort: UInt64, + priority: UInt8, + coaTXTypeEnum: UInt8, + amount: UFix64?, + callToEVMAddress: [UInt8; 20]?, + data: [UInt8]?, + gasLimit: UInt64?, + value: UFix64?, + testName: String, + failWithErr: String? +) { + var tx = Test.Transaction( + code: Test.readFile("../transactions/transactionScheduler/schedule_coa_transaction.cdc"), + authorizers: [admin.address], + signers: [admin], + arguments: [timestamp, fee, effort, priority, coaTXTypeEnum, amount, callToEVMAddress, data, gasLimit, value], + ) + var result = Test.executeTransaction(tx) + + if let error = failWithErr { + // log(error) + // log(result.error!.message) + Test.expect(result, Test.beFailed()) + Test.assertError( + result, + errorMessage: error + ) + + } else { + if result.error != nil { + Test.assert(result.error == nil, message: "Transaction failed with error: \(result.error!.message) for test case: \(testName)") + } + } +} + access(all) fun cancelTransaction(id: UInt64, failWithErr: String?) { var tx = Test.Transaction( code: Test.readFile("../transactions/transactionScheduler/cancel_transaction.cdc"), diff --git a/tests/transactionScheduler_coa_test.cdc b/tests/transactionScheduler_coa_test.cdc new file mode 100644 index 00000000..668868e8 --- /dev/null +++ b/tests/transactionScheduler_coa_test.cdc @@ -0,0 +1,62 @@ +import Test +import BlockchainHelpers +import "FlowTransactionScheduler" +import "FlowToken" +import "FlowTransactionSchedulerUtils" + +import "scheduled_transaction_test_helpers.cdc" + +access(all) var startingHeight: UInt64 = 0 + +access(all) let depositFLOWEnum: UInt8 = 0 +access(all) let withdrawFLOWEnum: UInt8 = 1 +access(all) let callEnum: UInt8 = 2 + +access(all) +fun setup() { + + var err = Test.deployContract( + name: "FlowTransactionScheduler", + path: "../contracts/FlowTransactionScheduler.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "FlowTransactionSchedulerUtils", + path: "../contracts/FlowTransactionSchedulerUtils.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + fundAccountWithFlow(to: admin.address, amount: 10000.0) + + startingHeight = getCurrentBlockHeight() + +} + +/** --------------------------------------------------------------------------------- + Transaction handler integration tests + --------------------------------------------------------------------------------- */ + +access(all) fun testCOAScheduledTransactions() { + + let currentTime = getTimestamp() + let timeInFuture = currentTime + futureDelta + + // Schedule high priority transaction + scheduleCOATransaction( + timestamp: timeInFuture, + fee: feeAmount, + effort: basicEffort, + priority: highPriority, + coaTXTypeEnum: depositFLOWEnum, + amount: 100.0, + callToEVMAddress: nil, + data: nil, + gasLimit: nil, + value: nil, + testName: "Test COA Transaction Scheduling: Deposit FLOW", + failWithErr: nil + ) +} diff --git a/tests/transactionScheduler_test.cdc b/tests/transactionScheduler_test.cdc index 4ab4a84c..1a60c250 100644 --- a/tests/transactionScheduler_test.cdc +++ b/tests/transactionScheduler_test.cdc @@ -138,6 +138,11 @@ access(all) fun testEstimate() { executionEffortCost: 24.99249924 ) + let largeArray: [Int] = [] + while largeArray.length < 10000 { + largeArray.append(1) + } + let currentTime = getCurrentBlock().timestamp let futureTime = currentTime + 100.0 let pastTime = currentTime - 100.0 @@ -215,6 +220,16 @@ access(all) fun testEstimate() { expectedTimestamp: nil, expectedError: "Invalid execution effort: \(lowPriorityMaxEffort + 1) is greater than the priority's max effort of \(lowPriorityMaxEffort)" ), + EstimateTestCase( + name: "Excessive data size returns error", + timestamp: futureTime + 11.0, + priority: FlowTransactionScheduler.Priority.High, + executionEffort: 1000, + data: largeArray, + expectedFee: nil, + expectedTimestamp: nil, + expectedError: "Invalid data size: 0.05337100 is greater than the maximum data size of 0.00100000MB" + ), // Valid cases - should return EstimatedScheduledTransaction with no error EstimateTestCase( diff --git a/transactions/transactionScheduler/schedule_coa_transaction.cdc b/transactions/transactionScheduler/schedule_coa_transaction.cdc new file mode 100644 index 00000000..4427aae7 --- /dev/null +++ b/transactions/transactionScheduler/schedule_coa_transaction.cdc @@ -0,0 +1,113 @@ +import "FlowTransactionScheduler" +import "FlowTransactionSchedulerUtils" +import "FlowToken" +import "FungibleToken" +import "EVM" + +transaction( + timestamp: UFix64, + feeAmount: UFix64, + effort: UInt64, + priority: UInt8, + coaTXTypeEnum: UInt8, + amount: UFix64?, + callToEVMAddress: [UInt8; 20]?, + data: [UInt8]?, + gasLimit: UInt64?, + value: UFix64? +) { + + prepare(account: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, GetStorageCapabilityController) &Account) { + + // if a transaction scheduler manager has not been created for this account yet, create one + if !account.storage.check<@{FlowTransactionSchedulerUtils.Manager}>(from: FlowTransactionSchedulerUtils.managerStoragePath) { + let manager <- FlowTransactionSchedulerUtils.createManager() + account.storage.save(<-manager, to: FlowTransactionSchedulerUtils.managerStoragePath) + + // create a public capability to the callback manager + let managerRef = account.capabilities.storage.issue<&{FlowTransactionSchedulerUtils.Manager}>(FlowTransactionSchedulerUtils.managerStoragePath) + account.capabilities.publish(managerRef, at: FlowTransactionSchedulerUtils.managerPublicPath) + } + + // If a COA transaction handler has not been created for this account yet, create one, + // store it, and issue a capability that will be used to create the transaction + if !account.storage.check<@FlowTransactionSchedulerUtils.COATransactionHandler>(from: FlowTransactionSchedulerUtils.coaHandlerStoragePath()) { + + var coaCapability: Capability? = nil + + // get the COA capability + if account.capabilities.storage.getControllers(forPath: /storage/coa).length > 0 { + coaCapability = account.capabilities.storage.getControllers(forPath: /storage/coa)[0].capability as! Capability + } else { + coaCapability = account.capabilities.storage.issue(/storage/coa) + } + + var flowTokenVaultCapability: Capability? = nil + + // get the FlowToken Vault capability + if let newFlowTokenVaultCapability = account.capabilities.storage + .getControllers(forPath: /storage/flowTokenVault)[0] + .capability as? Capability { + flowTokenVaultCapability = newFlowTokenVaultCapability + } else { + flowTokenVaultCapability = account.capabilities.storage.issue(/storage/flowTokenVault) + } + + let handler <- FlowTransactionSchedulerUtils.createCOATransactionHandler( + coaCapability: coaCapability!, + flowTokenVaultCapability: flowTokenVaultCapability! + ) + + account.storage.save(<-handler, to: FlowTransactionSchedulerUtils.coaHandlerStoragePath()) + account.capabilities.storage.issue(FlowTransactionSchedulerUtils.coaHandlerStoragePath()) + + let publicHandlerCap = account.capabilities.storage.issue<&{FlowTransactionScheduler.TransactionHandler}>(FlowTransactionSchedulerUtils.coaHandlerStoragePath()) + account.capabilities.publish(publicHandlerCap, at: FlowTransactionSchedulerUtils.coaHandlerPublicPath()) + } + + // Get the entitled capability that will be used to create the transaction + // Need to check both controllers because the order of controllers is not guaranteed + var handlerCap: Capability? = nil + + if let cap = account.capabilities.storage + .getControllers(forPath: FlowTransactionSchedulerUtils.coaHandlerStoragePath())[0] + .capability as? Capability { + handlerCap = cap + } else { + handlerCap = account.capabilities.storage + .getControllers(forPath: FlowTransactionSchedulerUtils.coaHandlerStoragePath())[1] + .capability as! Capability + } + + // borrow a reference to the vault that will be used for fees + let vault = account.storage.borrow(from: /storage/flowTokenVault) + ?? panic("Could not borrow FlowToken vault") + + let fees <- vault.withdraw(amount: feeAmount) as! @FlowToken.Vault + let priorityEnum = FlowTransactionScheduler.Priority(rawValue: priority) + ?? FlowTransactionScheduler.Priority.High + + // borrow a reference to the callback manager + let manager = account.storage.borrow(from: FlowTransactionSchedulerUtils.managerStoragePath) + ?? panic("Could not borrow a Manager reference from \(FlowTransactionSchedulerUtils.managerStoragePath)") + + + let coaHandlerParams = FlowTransactionSchedulerUtils.COAHandlerParams( + txType: coaTXTypeEnum, + amount: amount, + callToEVMAddress: callToEVMAddress, + data: data, + gasLimit: gasLimit, + value: value + ) + // Schedule the COA transaction with the main contract + manager.schedule( + handlerCap: handlerCap!, + data: coaHandlerParams, + timestamp: timestamp, + priority: priorityEnum, + executionEffort: effort, + fees: <-fees + ) + } +}