From 72da2e48692a689935877f38c61f88005c681ee2 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 30 Mar 2026 09:23:39 -0700 Subject: [PATCH 1/9] Add helpers for time series simulations --- .../mocks/FlowTransactionScheduler.cdc | 1454 +++++++++++++++++ cadence/tests/test_helpers.cdc | 11 +- .../tests/transactions/reset_scheduler.cdc | 12 + .../tests/transactions/set_erc20_balance.cdc | 36 + cadence/tests/transactions/transfer_wbtc.cdc | 30 + flow.json | 8 + 6 files changed, 1550 insertions(+), 1 deletion(-) create mode 100644 cadence/contracts/mocks/FlowTransactionScheduler.cdc create mode 100644 cadence/tests/transactions/reset_scheduler.cdc create mode 100644 cadence/tests/transactions/set_erc20_balance.cdc create mode 100644 cadence/tests/transactions/transfer_wbtc.cdc diff --git a/cadence/contracts/mocks/FlowTransactionScheduler.cdc b/cadence/contracts/mocks/FlowTransactionScheduler.cdc new file mode 100644 index 00000000..642b938f --- /dev/null +++ b/cadence/contracts/mocks/FlowTransactionScheduler.cdc @@ -0,0 +1,1454 @@ +import "FungibleToken" +import "FlowToken" +import "FlowFees" +import "FlowStorageFees" +import "ViewResolver" + +/// FlowTransactionScheduler enables smart contracts to schedule autonomous execution in the future. +/// +/// This contract implements FLIP 330's scheduled transaction system, allowing contracts to "wake up" and execute +/// logic at predefined times without external triggers. +/// +/// Scheduled transactions are prioritized (High/Medium/Low) with different execution guarantees and fee multipliers: +/// - High priority guarantees first-block execution at the exact requested timestamp (fails if slot is full). +/// - Medium priority provides best-effort scheduling, shifting to the next available slot if the requested one is full. +/// - Low priority shifts to the next available slot if the requested one is full. +/// +/// Each priority level has its own independent effort pool per time slot with no shared capacity between priorities. +/// Low priority transactions are never rescheduled by higher priority transactions. +/// +/// The system uses time slots with execution effort limits to manage network resources, +/// ensuring predictable performance while enabling novel autonomous blockchain patterns like recurring +/// payments, automated arbitrage, and time-based contract logic. +access(all) contract FlowTransactionScheduler { + + /// singleton instance used to store all scheduled transaction data + /// and route all scheduled transaction functionality + access(self) var sharedScheduler: Capability + + /// storage path for the singleton scheduler resource + access(all) let storagePath: StoragePath + + /// Enums + + /// Priority + access(all) enum Priority: UInt8 { + access(all) case High + access(all) case Medium + access(all) case Low + } + + /// Status + access(all) enum Status: UInt8 { + /// unknown statuses are used for handling historic scheduled transactions with null statuses + access(all) case Unknown + /// mutable status + access(all) case Scheduled + /// finalized statuses + access(all) case Executed + access(all) case Canceled + } + + /// Events + + /// Emitted when a transaction is scheduled + access(all) event Scheduled( + id: UInt64, + priority: UInt8, + timestamp: UFix64, + executionEffort: UInt64, + fees: UFix64, + transactionHandlerOwner: Address, + transactionHandlerTypeIdentifier: String, + transactionHandlerUUID: UInt64, + + // The public path of the transaction handler that can be used to resolve views + // DISCLAIMER: There is no guarantee that the public path is accurate + transactionHandlerPublicPath: PublicPath? + ) + + /// Emitted when a scheduled transaction's scheduled timestamp is reached and it is ready for execution + access(all) event PendingExecution( + id: UInt64, + priority: UInt8, + executionEffort: UInt64, + fees: UFix64, + transactionHandlerOwner: Address, + transactionHandlerTypeIdentifier: String + ) + + /// Emitted when a scheduled transaction is executed by the FVM + access(all) event Executed( + id: UInt64, + priority: UInt8, + executionEffort: UInt64, + transactionHandlerOwner: Address, + transactionHandlerTypeIdentifier: String, + transactionHandlerUUID: UInt64, + + // The public path of the transaction handler that can be used to resolve views + // DISCLAIMER: There is no guarantee that the public path is accurate + transactionHandlerPublicPath: PublicPath? + ) + + /// Emitted when a scheduled transaction is canceled by the creator of the transaction + access(all) event Canceled( + id: UInt64, + priority: UInt8, + feesReturned: UFix64, + feesDeducted: UFix64, + transactionHandlerOwner: Address, + transactionHandlerTypeIdentifier: String + ) + + /// Emitted when a collection limit is reached + /// The limit that was reached is non-nil and is the limit that was reached + /// The other limit that was not reached is nil + access(all) event CollectionLimitReached( + collectionEffortLimit: UInt64?, + collectionTransactionsLimit: Int? + ) + + /// Emitted when the limit on the number of transactions that can be removed in process() is reached + access(all) event RemovalLimitReached() + + // Emitted when one or more of the configuration details fields are updated + // Event listeners can listen to this and query the new configuration + // if they need to + access(all) event ConfigUpdated() + + // Emitted when a critical issue is encountered + access(all) event CriticalIssue(message: String) + + /// Entitlements + access(all) entitlement Execute + access(all) entitlement Process + access(all) entitlement Cancel + access(all) entitlement UpdateConfig + + /// Interfaces + + /// TransactionHandler is an interface that defines a single method executeTransaction that + /// must be implemented by the resource that contains the logic to be executed by the scheduled transaction. + /// An authorized capability to this resource is provided when scheduling a transaction. + /// The transaction scheduler uses this capability to execute the transaction when its scheduled timestamp arrives. + access(all) resource interface TransactionHandler: ViewResolver.Resolver { + + access(all) view fun getViews(): [Type] { + return [] + } + + access(all) fun resolveView(_ view: Type): AnyStruct? { + return nil + } + + /// Executes the implemented transaction logic + /// + /// @param id: The id of the scheduled transaction (this can be useful for any internal tracking) + /// @param data: The data that was passed when the transaction was originally scheduled + /// that may be useful for the execution of the transaction logic + access(Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) + } + + /// Structs + + /// ScheduledTransaction is the resource that the user receives after scheduling a transaction. + /// It allows them to get the status of their transaction and can be passed back + /// to the scheduler contract to cancel the transaction if it has not yet been executed. + access(all) resource ScheduledTransaction { + access(all) let id: UInt64 + access(all) let timestamp: UFix64 + access(all) let handlerTypeIdentifier: String + + access(all) view fun status(): Status? { + return FlowTransactionScheduler.sharedScheduler.borrow()!.getStatus(id: self.id) + } + + init( + id: UInt64, + timestamp: UFix64, + handlerTypeIdentifier: String + ) { + self.id = id + self.timestamp = timestamp + self.handlerTypeIdentifier = handlerTypeIdentifier + } + + // event emitted when the resource is destroyed + access(all) event ResourceDestroyed(id: UInt64 = self.id, timestamp: UFix64 = self.timestamp, handlerTypeIdentifier: String = self.handlerTypeIdentifier) + } + + /// EstimatedScheduledTransaction contains data for estimating transaction scheduling. + access(all) struct EstimatedScheduledTransaction { + /// flowFee is the estimated fee in Flow for the transaction to be scheduled + access(all) let flowFee: UFix64? + /// timestamp is estimated timestamp that the transaction will be executed at + access(all) let timestamp: UFix64? + /// error is an optional error message if the transaction cannot be scheduled + access(all) let error: String? + + access(contract) view init(flowFee: UFix64?, timestamp: UFix64?, error: String?) { + self.flowFee = flowFee + self.timestamp = timestamp + self.error = error + } + } + + /// Transaction data is a representation of a scheduled transaction + /// It is the source of truth for an individual transaction and stores the + /// capability to the handler that contains the logic that will be executed by the transaction. + access(all) struct TransactionData { + access(all) let id: UInt64 + access(all) let priority: Priority + access(all) let executionEffort: UInt64 + access(all) var status: Status + + /// Fee amount to pay for the transaction + access(all) let fees: UFix64 + + /// The timestamp that the transaction is scheduled for + /// For medium priority transactions, it may be different than the requested timestamp + /// For low priority transactions, it is the requested timestamp, + /// but the timestamp where the transaction is actually executed may be different + access(all) var scheduledTimestamp: UFix64 + + /// Capability to the logic that the transaction will execute + access(contract) let handler: Capability + + /// Type identifier of the transaction handler + access(all) let handlerTypeIdentifier: String + access(all) let handlerAddress: Address + + /// Optional data that can be passed to the handler + /// This data is publicly accessible, so make sure it does not contain + /// any privileged information or functionality + access(contract) let data: AnyStruct? + + access(contract) init( + id: UInt64, + handler: Capability, + scheduledTimestamp: UFix64, + data: AnyStruct?, + priority: Priority, + executionEffort: UInt64, + fees: UFix64, + ) { + self.id = id + self.handler = handler + self.data = data + self.priority = priority + self.executionEffort = executionEffort + self.fees = fees + self.status = Status.Scheduled + let handlerRef = handler.borrow() + ?? panic("Invalid transaction handler: Could not borrow a reference to the transaction handler") + self.handlerAddress = handler.address + self.handlerTypeIdentifier = handlerRef.getType().identifier + self.scheduledTimestamp = scheduledTimestamp + } + + /// setStatus updates the status of the transaction. + /// It panics if the transaction status is already finalized. + access(contract) fun setStatus(newStatus: Status) { + pre { + newStatus != Status.Unknown: "Invalid status: New status cannot be Unknown" + self.status != Status.Executed && self.status != Status.Canceled: + "Invalid status: Transaction with id \(self.id) is already finalized" + newStatus == Status.Executed ? self.status == Status.Scheduled : true: + "Invalid status: Transaction with id \(self.id) can only be set as Executed if it is Scheduled" + newStatus == Status.Canceled ? self.status == Status.Scheduled : true: + "Invalid status: Transaction with id \(self.id) can only be set as Canceled if it is Scheduled" + } + + self.status = newStatus + } + + /// setScheduledTimestamp updates the scheduled timestamp of the transaction. + /// It panics if the transaction status is already finalized. + access(contract) fun setScheduledTimestamp(newTimestamp: UFix64) { + pre { + self.status != Status.Executed && self.status != Status.Canceled: + "Invalid status: Transaction with id \(self.id) is already finalized" + } + self.scheduledTimestamp = newTimestamp + } + + /// payAndRefundFees withdraws fees from the transaction based on the refund multiplier. + /// It deposits any leftover fees to the FlowFees vault to be used to pay node operator rewards + /// like any other transaction on the Flow network. + access(contract) fun payAndRefundFees(refundMultiplier: UFix64): @FlowToken.Vault { + pre { + refundMultiplier >= 0.0 && refundMultiplier <= 1.0: + "Invalid refund multiplier: The multiplier must be between 0.0 and 1.0 but got \(refundMultiplier)" + } + if refundMultiplier == 0.0 { + FlowFees.deposit(from: <-FlowTransactionScheduler.withdrawFees(amount: self.fees)) + return <-FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>()) + } else { + let amountToReturn = self.fees * refundMultiplier + let amountToKeep = self.fees - amountToReturn + let feesToReturn <- FlowTransactionScheduler.withdrawFees(amount: amountToReturn) + FlowFees.deposit(from: <-FlowTransactionScheduler.withdrawFees(amount: amountToKeep)) + return <-feesToReturn + } + } + + /// getData copies and returns the data field + access(all) view fun getData(): AnyStruct? { + return self.data + } + + /// borrowHandler returns an un-entitled reference to the transaction handler + /// This allows users to query metadata views about the handler + /// @return: An un-entitled reference to the transaction handler + access(all) view fun borrowHandler(): &{TransactionHandler} { + return self.handler.borrow() as? &{TransactionHandler} + ?? panic("Invalid transaction handler: Could not borrow a reference to the transaction handler") + } + } + + /// Struct interface representing all the base configuration details in the Scheduler contract + /// that is used for governing the protocol + /// This is an interface to allow for the configuration details to be updated in the future + access(all) struct interface SchedulerConfig { + + /// maximum effort that can be used for any transaction + access(all) var maximumIndividualEffort: UInt64 + + /// minimum execution effort is the minimum effort that can be + /// used for any transaction + access(all) var minimumExecutionEffort: UInt64 + + /// slot total effort limit is the maximum effort that can be + /// cumulatively allocated to one timeslot by all priorities + access(all) var slotTotalEffortLimit: UInt64 + + /// slot shared effort limit is the maximum effort + /// that can be allocated to high and medium priority + /// transactions combined after their exclusive effort reserves have been filled + access(all) var slotSharedEffortLimit: UInt64 + + /// priority effort reserve is the amount of effort that is + /// reserved exclusively for each priority + access(all) var priorityEffortReserve: {Priority: UInt64} + + /// priority effort limit is the maximum cumulative effort per priority in a timeslot + access(all) var priorityEffortLimit: {Priority: UInt64} + + /// max data size is the maximum data size that can be stored for a transaction + access(all) var maxDataSizeMB: UFix64 + + /// priority fee multipliers are values we use to calculate the added + /// processing fee for each priority + access(all) var priorityFeeMultipliers: {Priority: UFix64} + + /// refund multiplier is the portion of the fees that are refunded when any transaction is cancelled + access(all) var refundMultiplier: UFix64 + + /// canceledTransactionsLimit is the maximum number of canceled transactions + /// to keep in the canceledTransactions array + access(all) var canceledTransactionsLimit: UInt + + /// collectionEffortLimit is the maximum effort that can be used for all transactions in a collection + access(all) var collectionEffortLimit: UInt64 + + /// collectionTransactionsLimit is the maximum number of transactions that can be processed in a collection + access(all) var collectionTransactionsLimit: Int + + access(all) init( + maximumIndividualEffort: UInt64, + minimumExecutionEffort: UInt64, + priorityEffortLimit: {Priority: UInt64}, + maxDataSizeMB: UFix64, + priorityFeeMultipliers: {Priority: UFix64}, + refundMultiplier: UFix64, + canceledTransactionsLimit: UInt, + collectionEffortLimit: UInt64, + collectionTransactionsLimit: Int, + txRemovalLimit: UInt + ) { + post { + self.refundMultiplier >= 0.0 && self.refundMultiplier <= 1.0: + "Invalid refund multiplier: The multiplier must be between 0.0 and 1.0 but got \(refundMultiplier)" + self.priorityFeeMultipliers[Priority.Low]! >= 1.0: + "Invalid priority fee multiplier: Low priority multiplier must be greater than or equal to 1.0 but got \(self.priorityFeeMultipliers[Priority.Low]!)" + self.priorityFeeMultipliers[Priority.Medium]! > self.priorityFeeMultipliers[Priority.Low]!: + "Invalid priority fee multiplier: Medium priority multiplier must be greater than or equal to \(priorityFeeMultipliers[Priority.Low]!) but got \(priorityFeeMultipliers[Priority.Medium]!)" + self.priorityFeeMultipliers[Priority.High]! > self.priorityFeeMultipliers[Priority.Medium]!: + "Invalid priority fee multiplier: High priority multiplier must be greater than or equal to \(priorityFeeMultipliers[Priority.Medium]!) but got \(priorityFeeMultipliers[Priority.High]!)" + self.priorityEffortLimit[Priority.Low]! > 0: + "Invalid priority effort limit: Low priority effort limit must be greater than 0" + self.priorityEffortLimit[Priority.Medium]! > self.priorityEffortLimit[Priority.Low]!: + "Invalid priority effort limit: Medium priority effort limit must be greater than the low priority effort limit of \(priorityEffortLimit[Priority.Low]!)" + self.priorityEffortLimit[Priority.High]! > self.priorityEffortLimit[Priority.Medium]!: + "Invalid priority effort limit: High priority effort limit must be greater than the medium priority effort limit of \(priorityEffortLimit[Priority.Medium]!)" + self.collectionTransactionsLimit >= 0: + "Invalid collection transactions limit: Collection transactions limit must be greater than or equal to 0 but got \(collectionTransactionsLimit)" + self.canceledTransactionsLimit >= 1: + "Invalid canceled transactions limit: Canceled transactions limit must be greater than or equal to 1 but got \(canceledTransactionsLimit)" + self.collectionEffortLimit > self.slotTotalEffortLimit: + "Invalid collection effort limit: Collection effort limit must be greater than \(self.slotTotalEffortLimit) but got \(self.collectionEffortLimit)" + } + } + + access(all) view fun getTxRemovalLimit(): UInt + } + + /// Concrete implementation of the SchedulerConfig interface + /// This struct is used to store the configuration details in the Scheduler contract + access(all) struct Config: SchedulerConfig { + access(all) var maximumIndividualEffort: UInt64 + access(all) var minimumExecutionEffort: UInt64 + access(all) var slotTotalEffortLimit: UInt64 + access(all) var slotSharedEffortLimit: UInt64 + access(all) var priorityEffortReserve: {Priority: UInt64} + access(all) var priorityEffortLimit: {Priority: UInt64} + access(all) var maxDataSizeMB: UFix64 + access(all) var priorityFeeMultipliers: {Priority: UFix64} + access(all) var refundMultiplier: UFix64 + access(all) var canceledTransactionsLimit: UInt + access(all) var collectionEffortLimit: UInt64 + access(all) var collectionTransactionsLimit: Int + + access(all) init( + maximumIndividualEffort: UInt64, + minimumExecutionEffort: UInt64, + priorityEffortLimit: {Priority: UInt64}, + maxDataSizeMB: UFix64, + priorityFeeMultipliers: {Priority: UFix64}, + refundMultiplier: UFix64, + canceledTransactionsLimit: UInt, + collectionEffortLimit: UInt64, + collectionTransactionsLimit: Int, + txRemovalLimit: UInt + ) { + self.maximumIndividualEffort = maximumIndividualEffort + self.minimumExecutionEffort = minimumExecutionEffort + self.priorityEffortLimit = priorityEffortLimit + // Legacy fields kept for storage compatibility; not used by scheduling logic + self.slotTotalEffortLimit = priorityEffortLimit[Priority.High]! + priorityEffortLimit[Priority.Medium]! + priorityEffortLimit[Priority.Low]! + self.slotSharedEffortLimit = 0 + self.priorityEffortReserve = {Priority.High: priorityEffortLimit[Priority.High]!, Priority.Medium: priorityEffortLimit[Priority.Medium]!, Priority.Low: priorityEffortLimit[Priority.Low]!} + self.maxDataSizeMB = maxDataSizeMB + self.priorityFeeMultipliers = priorityFeeMultipliers + self.refundMultiplier = refundMultiplier + self.canceledTransactionsLimit = canceledTransactionsLimit + self.collectionEffortLimit = collectionEffortLimit + self.collectionTransactionsLimit = collectionTransactionsLimit + } + + access(all) view fun getTxRemovalLimit(): UInt { + return FlowTransactionScheduler.account.storage.copy(from: /storage/txRemovalLimit) + ?? 200 + } + } + + + /// SortedTimestamps maintains timestamps sorted in ascending order for efficient processing + /// It encapsulates all operations related to maintaining and querying sorted timestamps + access(all) struct SortedTimestamps { + /// Internal sorted array of timestamps + access(self) var timestamps: [UFix64] + + access(all) init() { + self.timestamps = [] + } + + /// bisect is a function that finds the index to insert a new timestamp in the sorted array. + /// taken from bisect_right in pthon https://stackoverflow.com/questions/2945017/javas-equivalent-to-bisect-in-python + /// @param new: The new timestamp to insert + /// @return: The index to insert the new timestamp at or nil if the timestamp is already in the array + access(all) fun bisect(new: UFix64): Int? { + var high = self.timestamps.length + var low = 0 + while low < high { + let mid = (low+high)/2 + let midTimestamp = self.timestamps[mid] + + if midTimestamp == new { + return nil + } else if midTimestamp > new { + high = mid + } else { + low = mid + 1 + } + } + return low + } + + /// Add a timestamp to the sorted array maintaining sorted order + access(all) fun add(timestamp: UFix64) { + // Only insert if the timestamp is not already in the array + if let insertIndex = self.bisect(new: timestamp) { + self.timestamps.insert(at: insertIndex, timestamp) + } + } + + /// Remove a timestamp from the sorted array + access(all) fun remove(timestamp: UFix64) { + // Only remove if the timestamp is in the array + if let index = self.timestamps.firstIndex(of: timestamp) { + self.timestamps.remove(at: index) + } + } + + /// Get all timestamps that are in the past (less than or equal to current timestamp) + access(all) fun getBefore(current: UFix64): [UFix64] { + let pastTimestamps: [UFix64] = [] + for timestamp in self.timestamps { + if timestamp <= current { + pastTimestamps.append(timestamp) + } else { + break // No need to check further since array is sorted + } + } + return pastTimestamps + } + + /// Check if there are any timestamps that need processing + /// Returns true if processing is needed, false for early exit + access(all) fun hasBefore(current: UFix64): Bool { + return self.timestamps.length > 0 && self.timestamps[0] <= current + } + + /// Get the whole array of timestamps + access(all) fun getAll(): [UFix64] { + return self.timestamps + } + } + + /// Resources + + /// Shared scheduler is a resource that is used as a singleton in the scheduler contract and contains + /// all the functionality to schedule, process and execute transactions as well as the internal state. + access(all) resource SharedScheduler { + /// nextID contains the next transaction ID to be assigned + /// This the ID is monotonically increasing and is used to identify each transaction + access(contract) var nextID: UInt64 + + /// transactions is a map of transaction IDs to TransactionData structs + access(contract) var transactions: {UInt64: TransactionData} + + /// slot queue is a map of timestamps to Priorities to transaction IDs and their execution efforts + access(contract) var slotQueue: {UFix64: {Priority: {UInt64: UInt64}}} + + /// slot used effort is a map of timestamps map of priorities and + /// efforts that has been used for the timeslot + access(contract) var slotUsedEffort: {UFix64: {Priority: UInt64}} + + /// sorted timestamps manager for efficient processing + access(contract) var sortedTimestamps: SortedTimestamps + + /// canceled transactions keeps a record of canceled transaction IDs up to a canceledTransactionsLimit + access(contract) var canceledTransactions: [UInt64] + + /// Struct that contains all the configuration details for the transaction scheduler protocol + /// Can be updated by the owner of the contract + access(contract) var config: {SchedulerConfig} + + access(all) init() { + self.nextID = 1 + self.canceledTransactions = [0 as UInt64] + + self.transactions = {} + self.slotUsedEffort = {} + self.slotQueue = {} + self.sortedTimestamps = SortedTimestamps() + + /* Default slot efforts - each priority has its own independent pool: + + Timestamp Slot (25kee total) + ┌─────────────────────────┐ + │ ┌─────────────────────┐ │ High: 15kee — fail if full + │ │ High Pool 15kee │ │ + │ └─────────────────────┘ │ + │ ┌─────────────────────┐ │ Medium: 7.5kee — shift to next slot if full + │ │ Medium Pool 7.5kee │ │ + │ └─────────────────────┘ │ + │ ┌─────────────────────┐ │ Low: 2.5kee — shift to next slot if full + │ │ Low Pool 2.5kee │ │ + │ └─────────────────────┘ │ + └─────────────────────────┘ + */ + + self.config = Config( + maximumIndividualEffort: 9999, + minimumExecutionEffort: 100, + priorityEffortLimit: { + Priority.High: 15_000, + Priority.Medium: 7_500, + Priority.Low: 2_500 + }, + maxDataSizeMB: 0.001, + priorityFeeMultipliers: { + Priority.High: 10.0, + Priority.Medium: 5.0, + Priority.Low: 2.0 + }, + refundMultiplier: 0.5, + canceledTransactionsLimit: 1000, + collectionEffortLimit: 500_000, // Maximum effort for all transactions in a collection + collectionTransactionsLimit: 150, // Maximum number of transactions in a collection + txRemovalLimit: 200 + ) + } + + /// Gets a copy of the struct containing all the configuration details + /// of the Scheduler resource + access(contract) view fun getConfig(): {SchedulerConfig} { + return self.config + } + + /// sets all the configuration details for the Scheduler resource + /// NOTE: This function is guarded by the UpdateConfig entitlement, which is an admin-only + /// capability. It is not callable by regular users. Any configuration changes (including + /// txRemovalLimit) require explicit authorization from the contract administrator. + access(UpdateConfig) fun setConfig(newConfig: {SchedulerConfig}, txRemovalLimit: UInt) { + self.config = newConfig + FlowTransactionScheduler.account.storage.load(from: /storage/txRemovalLimit) + FlowTransactionScheduler.account.storage.save(txRemovalLimit, to: /storage/txRemovalLimit) + emit ConfigUpdated() + } + + /// getTransaction returns a copy of the specified transaction + access(contract) view fun getTransaction(id: UInt64): TransactionData? { + return self.transactions[id] + } + + /// borrowTransaction borrows a reference to the specified transaction + access(contract) view fun borrowTransaction(id: UInt64): &TransactionData? { + return &self.transactions[id] + } + + /// getCanceledTransactions returns a copy of the canceled transactions array + access(contract) view fun getCanceledTransactions(): [UInt64] { + return self.canceledTransactions + } + + /// getTransactionsForTimeframe returns a dictionary of transactions scheduled within a specified time range, + /// organized by timestamp and priority with arrays of transaction IDs. + /// WARNING: If you provide a time range that is too large, the function will likely fail to complete + /// because the function will run out of gas. Keep the time range small. + /// + /// @param startTimestamp: The start timestamp (inclusive) for the time range + /// @param endTimestamp: The end timestamp (inclusive) for the time range + /// @return {UFix64: {Priority: [UInt64]}}: A dictionary mapping timestamps to priorities to arrays of transaction IDs + access(contract) fun getTransactionsForTimeframe(startTimestamp: UFix64, endTimestamp: UFix64): {UFix64: {UInt8: [UInt64]}} { + var transactionsInTimeframe: {UFix64: {UInt8: [UInt64]}} = {} + + // Validate input parameters + if startTimestamp > endTimestamp { + return transactionsInTimeframe + } + + // Get all timestamps that fall within the specified range + let allTimestampsBeforeEnd = self.sortedTimestamps.getBefore(current: endTimestamp) + + for timestamp in allTimestampsBeforeEnd { + // Check if this timestamp falls within our range + if timestamp < startTimestamp { continue } + + let transactionPriorities = self.slotQueue[timestamp] ?? {} + + var timestampTransactions: {UInt8: [UInt64]} = {} + + for priority in transactionPriorities.keys { + let transactionIDs = transactionPriorities[priority] ?? {} + var priorityTransactions: [UInt64] = [] + + for id in transactionIDs.keys { + priorityTransactions.append(id) + } + + if priorityTransactions.length > 0 { + timestampTransactions[priority.rawValue] = priorityTransactions + } + } + + if timestampTransactions.keys.length > 0 { + transactionsInTimeframe[timestamp] = timestampTransactions + } + + } + + return transactionsInTimeframe + } + + /// calculate fee by converting execution effort to a fee in Flow tokens. + /// @param executionEffort: The execution effort of the transaction + /// @param priority: The priority of the transaction + /// @param dataSizeMB: The size of the data that was passed when the transaction was originally scheduled + /// @return UFix64: The fee in Flow tokens that is required to pay for the transaction + access(contract) fun calculateFee(executionEffort: UInt64, priority: Priority, dataSizeMB: UFix64): UFix64 { + // Use the official FlowFees calculation + let baseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: UFix64(executionEffort)/100000000.0) + + // Scale the execution fee by the multiplier for the priority + let scaledExecutionFee = baseFee * self.config.priorityFeeMultipliers[priority]! + + // Calculate the FLOW required to pay for storage of the transaction data + let storageFee = FlowStorageFees.storageCapacityToFlow(dataSizeMB) + + // Add inclusion Flow fee for scheduled transactions + let inclusionFee = 0.00001 + + return scaledExecutionFee + storageFee + inclusionFee + } + + /// getNextIDAndIncrement returns the next ID and increments the ID counter + access(self) fun getNextIDAndIncrement(): UInt64 { + let nextID = self.nextID + self.nextID = self.nextID + 1 + return nextID + } + + /// get status of the scheduled transaction + /// @param id: The ID of the transaction to get the status of + /// @return Status: The status of the transaction, if the transaction is not found Unknown is returned. + access(contract) view fun getStatus(id: UInt64): Status? { + // if the transaction ID is greater than the next ID, it is not scheduled yet and has never existed + if id == 0 as UInt64 || id >= self.nextID { + return nil + } + + // This should always return Scheduled or Executed + if let tx = self.borrowTransaction(id: id) { + return tx.status + } + + // if the transaction was canceled and it is still not pruned from + // list return canceled status + if self.canceledTransactions.contains(id) { + return Status.Canceled + } + + // If we reach this point, the transaction is not in the active transactions map + // and not in canceledTransactions. Since Scheduled transactions always remain in + // the transactions map until execution, a transaction can only reach this code path + // after it has been executed and aged out. The inference below uses the sorted + // canceledTransactions array as a lower-bound anchor: if the requested ID is greater + // than the oldest known canceled ID, it must have been executed (not canceled), + // because any cancellation would have added it to the canceledTransactions array. + // NOTE: Scheduled (future) transactions cannot be incorrectly reported as Executed + // here — they are still in the transactions map and are returned as Scheduled above. + let firstCanceledID = self.canceledTransactions[0] + if id > firstCanceledID { + return Status.Executed + } + + // the transaction list was pruned and the transaction status might be + // either canceled or execute so we return unknown + return Status.Unknown + } + + /// schedule is the primary entry point for scheduling a new transaction within the scheduler contract. + /// If scheduling the transaction is not possible either due to invalid arguments or due to + /// unavailable slots, the function panics. + // + /// The schedule function accepts the following arguments: + /// @param: transaction: A capability to a resource in storage that implements the transaction handler + /// interface. This handler will be invoked at execution time and will receive the specified data payload. + /// @param: timestamp: Specifies the earliest block timestamp at which the transaction is eligible for execution + /// (Unix timestamp so fractional seconds values are ignored). It must be set in the future. + /// @param: priority: An enum value (`High`, `Medium`, or `Low`) that influences the scheduling behavior and determines + /// how soon after the timestamp the transaction will be executed. + /// @param: executionEffort: Defines the maximum computational resources allocated to the transaction. This also determines + /// the fee charged. Unused execution effort is not refunded. + /// @param: fees: A Vault resource containing sufficient funds to cover the required execution effort. + access(contract) fun schedule( + handlerCap: Capability, + data: AnyStruct?, + timestamp: UFix64, + priority: Priority, + executionEffort: UInt64, + fees: @FlowToken.Vault + ): @ScheduledTransaction { + + // Use the estimate function to validate inputs + let estimate = self.estimate( + data: data, + timestamp: timestamp, + priority: priority, + executionEffort: executionEffort + ) + + if estimate.error != nil { + panic(estimate.error!) + } + + assert ( + fees.balance >= estimate.flowFee!, + message: "Insufficient fees: The Fee balance of \(fees.balance) is not sufficient to pay the required amount of \(estimate.flowFee!) for execution of the transaction." + ) + + let transactionID = self.getNextIDAndIncrement() + let transactionData = TransactionData( + id: transactionID, + handler: handlerCap, + scheduledTimestamp: estimate.timestamp!, + data: data, + priority: priority, + executionEffort: executionEffort, + fees: fees.balance, + ) + + // Deposit the fees to the service account's vault + FlowTransactionScheduler.depositFees(from: <-fees) + + let handlerRef = handlerCap.borrow() + ?? panic("Invalid transaction handler: Could not borrow a reference to the transaction handler") + + let handlerPublicPath = handlerRef.resolveView(Type()) as? PublicPath + + emit Scheduled( + id: transactionData.id, + priority: transactionData.priority.rawValue, + timestamp: transactionData.scheduledTimestamp, + executionEffort: transactionData.executionEffort, + fees: transactionData.fees, + transactionHandlerOwner: transactionData.handler.address, + transactionHandlerTypeIdentifier: transactionData.handlerTypeIdentifier, + transactionHandlerUUID: handlerRef.uuid, + transactionHandlerPublicPath: handlerPublicPath + ) + + // Add the transaction to the slot queue and update the internal state + self.addTransaction(slot: estimate.timestamp!, txData: transactionData) + + return <-create ScheduledTransaction( + id: transactionID, + timestamp: estimate.timestamp!, + handlerTypeIdentifier: transactionData.handlerTypeIdentifier + ) + } + + /// The estimate function calculates the required fee in Flow and expected execution timestamp for + /// a transaction based on the requested timestamp, priority, and execution effort. + /// + /// If the provided arguments are invalid or the transaction cannot be scheduled (e.g., due to + /// insufficient computation effort or unavailable time slots) the estimate function + /// returns an EstimatedScheduledTransaction struct with a non-nil error message. + /// + /// This helps developers ensure sufficient funding and preview the expected scheduling window, + /// reducing the risk of unnecessary cancellations. + /// + /// V2: Each priority has its own independent pool. Low priority transactions receive a valid + /// timestamp estimate just like High and Medium priority transactions. + /// + /// @param data: The data that was passed when the transaction was originally scheduled + /// @param timestamp: The requested timestamp for the transaction + /// @param priority: The priority of the transaction + /// @param executionEffort: The execution effort of the transaction + /// @return EstimatedScheduledTransaction: A struct containing the estimated fee, timestamp, and error message + access(contract) fun estimate( + data: AnyStruct?, + timestamp: UFix64, + priority: Priority, + executionEffort: UInt64 + ): EstimatedScheduledTransaction { + // Remove fractional values from the timestamp + let sanitizedTimestamp = UFix64(UInt64(timestamp)) + + if sanitizedTimestamp <= getCurrentBlock().timestamp { + return EstimatedScheduledTransaction( + flowFee: nil, + timestamp: nil, + error: "Invalid timestamp: \(sanitizedTimestamp) is in the past, current timestamp: \(getCurrentBlock().timestamp)" + ) + } + + if executionEffort > self.config.maximumIndividualEffort { + return EstimatedScheduledTransaction( + flowFee: nil, + timestamp: nil, + error: "Invalid execution effort: \(executionEffort) is greater than the maximum transaction effort of \(self.config.maximumIndividualEffort)" + ) + } + + if executionEffort > self.config.priorityEffortLimit[priority]! { + return EstimatedScheduledTransaction( + flowFee: nil, + timestamp: nil, + error: "Invalid execution effort: \(executionEffort) is greater than the priority's max effort of \(self.config.priorityEffortLimit[priority]!)" + ) + } + + if executionEffort < self.config.minimumExecutionEffort { + return EstimatedScheduledTransaction( + flowFee: nil, + timestamp: nil, + error: "Invalid execution effort: \(executionEffort) is less than the minimum execution effort of \(self.config.minimumExecutionEffort)" + ) + } + + let dataSizeMB = FlowTransactionScheduler.getSizeOfData(data) + if dataSizeMB > self.config.maxDataSizeMB { + return EstimatedScheduledTransaction( + flowFee: nil, + timestamp: nil, + error: "Invalid data size: \(dataSizeMB) is greater than the maximum data size of \(self.config.maxDataSizeMB)MB" + ) + } + + let fee = self.calculateFee(executionEffort: executionEffort, priority: priority, dataSizeMB: dataSizeMB) + + let scheduledTimestamp = self.calculateScheduledTimestamp( + timestamp: sanitizedTimestamp, + priority: priority, + executionEffort: executionEffort + ) + + if scheduledTimestamp == nil { + return EstimatedScheduledTransaction( + flowFee: nil, + timestamp: nil, + error: "Invalid execution effort: \(executionEffort) is greater than the priority's available effort for the requested timestamp." + ) + } + + return EstimatedScheduledTransaction(flowFee: fee, timestamp: scheduledTimestamp, error: nil) + } + + /// calculateScheduledTimestamp calculates the timestamp at which a transaction + /// can be scheduled. It takes into account the priority of the transaction and + /// the execution effort. + /// - If the transaction is high priority, it returns the timestamp if there is enough + /// space or nil if there is no space left. + /// - If the transaction is medium or low priority and there is space left in the requested timestamp, + /// it returns the requested timestamp. If there is not enough space, it finds the next timestamp with space. + /// + /// @param timestamp: The requested timestamp for the transaction + /// @param priority: The priority of the transaction + /// @param executionEffort: The execution effort of the transaction + /// @return UFix64?: The timestamp at which the transaction can be scheduled, or nil if there is no space left for a high priority transaction + access(contract) view fun calculateScheduledTimestamp( + timestamp: UFix64, + priority: Priority, + executionEffort: UInt64 + ): UFix64? { + + var timestampToSearch = timestamp + + // If no available timestamps are found, this will eventually reach the gas limit and fail + // This is extremely unlikely + while true { + + let used = self.slotUsedEffort[timestampToSearch] + // if nothing is scheduled at this timestamp, we can schedule at provided timestamp + if used == nil { + return timestampToSearch + } + + let available = self.getSlotAvailableEffort(sanitizedTimestamp: timestampToSearch, priority: priority) + // if theres enough space, we can tentatively schedule at provided timestamp + if executionEffort <= available { + return timestampToSearch + } + + if priority == Priority.High { + // high priority demands scheduling at exact timestamp or failing + return nil + } + + timestampToSearch = timestampToSearch + 1.0 + } + + // should never happen + return nil + } + + /// slot available effort returns the amount of effort that is available for a given timestamp and priority. + /// Each priority has its own independent pool with no shared capacity between priorities. + /// @param sanitizedTimestamp: The timestamp to get the available effort for. It should already have been sanitized + /// in the calling function + /// @param priority: The priority to get the available effort for + /// @return UInt64: The amount of effort that is available for the given timestamp and priority + access(contract) view fun getSlotAvailableEffort(sanitizedTimestamp: UFix64, priority: Priority): UInt64 { + let limit = self.config.priorityEffortLimit[priority]! + + if !self.slotUsedEffort.containsKey(sanitizedTimestamp) { + return limit + } + + let slotPriorityEffortsUsed = &self.slotUsedEffort[sanitizedTimestamp]! as &{Priority: UInt64} + let used = slotPriorityEffortsUsed[priority] ?? 0 + return limit.saturatingSubtract(used) + } + + /// add transaction to the queue and updates all the internal state + access(self) fun addTransaction(slot: UFix64, txData: TransactionData) { + + // If nothing is in the queue for this slot, initialize the slot + if self.slotQueue[slot] == nil { + self.slotQueue[slot] = {} + + // This also means that the used effort record for this slot has not been initialized + self.slotUsedEffort[slot] = { + Priority.High: 0, + Priority.Medium: 0, + Priority.Low: 0 + } + + self.sortedTimestamps.add(timestamp: slot) + } + + // Add this transaction id to the slot + let transactionsForSlot = self.slotQueue[slot]! + if let priorityQueue = transactionsForSlot[txData.priority] { + priorityQueue[txData.id] = txData.executionEffort + transactionsForSlot[txData.priority] = priorityQueue + } else { + transactionsForSlot[txData.priority] = { + txData.id: txData.executionEffort + } + } + self.slotQueue[slot] = transactionsForSlot + + // Add the execution effort for this transaction to the per-priority total for the slot. + // NOTE: This addition cannot overflow in practice. executionEffort is validated against + // maximumIndividualEffort and priorityEffortLimit before reaching this point (in estimate()), + // and the cumulative slot total is bounded by priorityEffortLimit[priority] which is + // checked on every schedule() call. UInt64 max (~1.8e19) far exceeds any reachable sum. + let slotEfforts = &self.slotUsedEffort[slot]! as auth(Mutate) &{Priority: UInt64} + slotEfforts[txData.priority] = slotEfforts[txData.priority]! + txData.executionEffort + + // Store the transaction in the transactions map + self.transactions[txData.id] = txData + } + + /// remove the transaction from the slot queue. + access(self) fun removeTransaction(txData: &TransactionData): TransactionData { + + let transactionID = txData.id + let slot = txData.scheduledTimestamp + let transactionPriority = txData.priority + + // remove transaction object + let transactionObject = self.transactions.remove(key: transactionID)! + + // garbage collect slots + let transactionQueue = self.slotQueue[slot]! + + if let priorityQueue = transactionQueue[transactionPriority] { + priorityQueue[transactionID] = nil + if priorityQueue.keys.length == 0 { + transactionQueue.remove(key: transactionPriority) + } else { + transactionQueue[transactionPriority] = priorityQueue + } + } + self.slotQueue[slot] = transactionQueue + + // if the slot is now empty remove it from the maps + if transactionQueue.keys.length == 0 { + self.slotQueue.remove(key: slot) + self.slotUsedEffort.remove(key: slot) + + self.sortedTimestamps.remove(timestamp: slot) + } + + return transactionObject + } + + /// pendingQueue creates a list of transactions that are ready for execution. + /// For transaction to be ready for execution it must be scheduled. + /// + /// The queue is sorted by timestamp and then by priority (high, medium, low). + /// The queue will contain transactions from all timestamps that are in the past. + /// Low priority transactions will only be added if there is effort available in the slot. + /// The return value can be empty if there are no transactions ready for execution. + access(Process) fun pendingQueue(): [&TransactionData] { + let currentTimestamp = getCurrentBlock().timestamp + var pendingTransactions: [&TransactionData] = [] + + // total effort across different timestamps guards collection being over the effort limit + var collectionAvailableEffort = self.config.collectionEffortLimit + var transactionsAvailableCount = self.config.collectionTransactionsLimit + + // Collect past timestamps efficiently from sorted array + let pastTimestamps = self.sortedTimestamps.getBefore(current: currentTimestamp) + + for timestamp in pastTimestamps { + let transactionPriorities = self.slotQueue[timestamp] ?? {} + var high: [&TransactionData] = [] + var medium: [&TransactionData] = [] + var low: [&TransactionData] = [] + + for priority in transactionPriorities.keys { + let transactionIDs = transactionPriorities[priority] ?? {} + for id in transactionIDs.keys { + let tx = self.borrowTransaction(id: id) + if tx == nil { + emit CriticalIssue(message: "Invalid ID: \(id) transaction not found while preparing pending queue") + continue + } + + // Only add scheduled transactions to the queue + if tx!.status != Status.Scheduled { + continue + } + + // this is safeguard to prevent collection growing too large in case of block production slowdown + if tx!.executionEffort >= collectionAvailableEffort || transactionsAvailableCount == 0 { + emit CollectionLimitReached( + collectionEffortLimit: transactionsAvailableCount == 0 ? nil : self.config.collectionEffortLimit, + collectionTransactionsLimit: transactionsAvailableCount == 0 ? self.config.collectionTransactionsLimit : nil + ) + break + } + + collectionAvailableEffort = collectionAvailableEffort.saturatingSubtract(tx!.executionEffort) + transactionsAvailableCount = transactionsAvailableCount - 1 + + switch tx!.priority { + case Priority.High: + high.append(tx!) + case Priority.Medium: + medium.append(tx!) + case Priority.Low: + low.append(tx!) + } + } + } + + pendingTransactions = pendingTransactions + .concat(high) + .concat(medium) + .concat(low) + } + + return pendingTransactions + } + + /// removeExecutedTransactions removes all transactions that are marked as executed. + access(self) fun removeExecutedTransactions(_ currentTimestamp: UFix64) { + let pastTimestamps = self.sortedTimestamps.getBefore(current: currentTimestamp) + var numRemoved = 0 + let removalLimit = self.config.getTxRemovalLimit() + + for timestamp in pastTimestamps { + let transactionPriorities = self.slotQueue[timestamp] ?? {} + + for priority in transactionPriorities.keys { + let transactionIDs = transactionPriorities[priority] ?? {} + for id in transactionIDs.keys { + + numRemoved = numRemoved + 1 + + if UInt(numRemoved) >= removalLimit { + emit RemovalLimitReached() + return + } + + let tx = self.borrowTransaction(id: id) + if tx == nil { + emit CriticalIssue(message: "Invalid ID: \(id) transaction not found while removing executed transactions") + continue + } + + // Only remove executed transactions + if tx!.status != Status.Executed { + continue + } + + // charge the full fee for transaction execution + destroy tx!.payAndRefundFees(refundMultiplier: 0.0) + + self.removeTransaction(txData: tx!) + } + } + } + } + + /// process scheduled transactions and prepare them for execution. + /// + /// First, it removes transactions that have already been executed. + /// Then, it iterates over past timestamps in the queue and processes the transactions that are + /// eligible for execution. It also emits an event for each transaction that is processed. + /// + /// This function is only called by the FVM to process transactions. + access(Process) fun process() { + let currentTimestamp = getCurrentBlock().timestamp + // Early exit if no timestamps need processing + if !self.sortedTimestamps.hasBefore(current: currentTimestamp) { + return + } + + self.removeExecutedTransactions(currentTimestamp) + + let pendingTransactions = self.pendingQueue() + + for tx in pendingTransactions { + + emit PendingExecution( + id: tx.id, + priority: tx.priority.rawValue, + executionEffort: tx.executionEffort, + fees: tx.fees, + transactionHandlerOwner: tx.handler.address, + // Cannot use the real type identifier here because if + // the handler contract is broken, it could cause the process function to fail + transactionHandlerTypeIdentifier: "" + ) + + // after pending execution event is emitted we set the transaction as executed because we + // must rely on execution node to actually execute it. Execution of the transaction is + // done in a separate transaction that calls executeTransaction(id) function. + // Executing the transaction can not update the status of transaction or any other shared state, + // since that blocks concurrent transaction execution. + // Therefore an optimistic update to executed is made here to avoid race condition. + tx.setStatus(newStatus: Status.Executed) + } + } + + /// cancel a scheduled transaction and return a portion of the fees that were paid. + /// + /// @param id: The ID of the transaction to cancel + /// @return: The fees to be returned to the caller + access(Cancel) fun cancel(id: UInt64): @FlowToken.Vault { + let tx = self.borrowTransaction(id: id) ?? + panic("Invalid ID: \(id) transaction not found") + + assert( + tx.status == Status.Scheduled, + message: "Transaction must be in a scheduled state in order to be canceled" + ) + + // Subtract the execution effort for this transaction from the slot's priority + let slotEfforts = self.slotUsedEffort[tx.scheduledTimestamp]! + slotEfforts[tx.priority] = slotEfforts[tx.priority]!.saturatingSubtract(tx.executionEffort) + self.slotUsedEffort[tx.scheduledTimestamp] = slotEfforts + + let totalFees = tx.fees + let refundedFees <- tx.payAndRefundFees(refundMultiplier: self.config.refundMultiplier) + + // if the transaction was canceled, add it to the canceled transactions array + // maintain sorted order by inserting at the correct position + var high = self.canceledTransactions.length + var low = 0 + while low < high { + let mid = (low+high)/2 + let midCanceledID = self.canceledTransactions[mid] + + if midCanceledID == id { + emit CriticalIssue(message: "Invalid ID: \(id) transaction already in canceled transactions array") + break + } else if midCanceledID > id { + high = mid + } else { + low = mid + 1 + } + } + self.canceledTransactions.insert(at: low, id) + + // keep the array under the limit + if UInt(self.canceledTransactions.length) > self.config.canceledTransactionsLimit { + self.canceledTransactions.remove(at: 0) + } + + emit Canceled( + id: tx.id, + priority: tx.priority.rawValue, + feesReturned: refundedFees.balance, + feesDeducted: totalFees - refundedFees.balance, + transactionHandlerOwner: tx.handler.address, + transactionHandlerTypeIdentifier: tx.handlerTypeIdentifier + ) + + self.removeTransaction(txData: tx) + + return <-refundedFees + } + + /// execute transaction is a system function that is called by FVM to execute a transaction by ID. + /// The transaction must be found and in correct state or the function panics and this is a fatal error + /// + /// This function is only called by the FVM to execute transactions. + /// WARNING: this function should not change any shared state, it will be run concurrently and it must not be blocking. + access(Execute) fun executeTransaction(id: UInt64) { + let tx = self.borrowTransaction(id: id) ?? + panic("Invalid ID: Transaction with id \(id) not found") + + assert ( + tx.status == Status.Executed, + message: "Invalid ID: Cannot execute transaction with id \(id) because it has incorrect status \(tx.status.rawValue)" + ) + + let transactionHandler = tx.handler.borrow() + ?? panic("Invalid transaction handler: Could not borrow a reference to the transaction handler") + + let handlerPublicPath = transactionHandler.resolveView(Type()) as? PublicPath + + emit Executed( + id: tx.id, + priority: tx.priority.rawValue, + executionEffort: tx.executionEffort, + transactionHandlerOwner: tx.handler.address, + transactionHandlerTypeIdentifier: transactionHandler.getType().identifier, + transactionHandlerUUID: transactionHandler.uuid, + transactionHandlerPublicPath: handlerPublicPath + + ) + + transactionHandler.executeTransaction(id: id, data: tx.getData()) + } + + /// Clears all queued/scheduled transactions, resetting the scheduler to an empty state. + access(Cancel) fun reset() { + self.transactions = {} + self.slotQueue = {} + self.slotUsedEffort = {} + self.sortedTimestamps = SortedTimestamps() + self.canceledTransactions = [0 as UInt64] + } + } + + /// Deposit fees to this contract's account's vault + access(contract) fun depositFees(from: @FlowToken.Vault) { + let vaultRef = self.account.storage.borrow<&FlowToken.Vault>(from: /storage/flowTokenVault) + ?? panic("Unable to borrow reference to the default token vault") + vaultRef.deposit(from: <-from) + } + + /// Withdraw fees from this contract's account's vault + access(contract) fun withdrawFees(amount: UFix64): @FlowToken.Vault { + let vaultRef = self.account.storage.borrow(from: /storage/flowTokenVault) + ?? panic("Unable to borrow reference to the default token vault") + + return <-vaultRef.withdraw(amount: amount) as! @FlowToken.Vault + } + + access(all) fun schedule( + handlerCap: Capability, + data: AnyStruct?, + timestamp: UFix64, + priority: Priority, + executionEffort: UInt64, + fees: @FlowToken.Vault + ): @ScheduledTransaction { + return <-self.sharedScheduler.borrow()!.schedule( + handlerCap: handlerCap, + data: data, + timestamp: timestamp, + priority: priority, + executionEffort: executionEffort, + fees: <-fees + ) + } + + access(all) fun estimate( + data: AnyStruct?, + timestamp: UFix64, + priority: Priority, + executionEffort: UInt64 + ): EstimatedScheduledTransaction { + return self.sharedScheduler.borrow()! + .estimate( + data: data, + timestamp: timestamp, + priority: priority, + executionEffort: executionEffort, + ) + } + + /// Allows users to calculate the fee for a scheduled transaction without having to call the expensive estimate function + /// @param executionEffort: The execution effort of the transaction + /// @param priority: The priority of the transaction + /// @param dataSizeMB: The size of the data to be stored with the scheduled transaction + /// The user must calculate this data size themselves before calling this function + /// But should be done in a separate script or transaction to avoid the expensive getSizeOfData function + /// @return UFix64: The fee in Flow tokens that is required to pay for the transaction + access(all) fun calculateFee(executionEffort: UInt64, priority: Priority, dataSizeMB: UFix64): UFix64 { + return self.sharedScheduler.borrow()!.calculateFee(executionEffort: executionEffort, priority: priority, dataSizeMB: dataSizeMB) + } + + access(all) fun cancel(scheduledTx: @ScheduledTransaction): @FlowToken.Vault { + let id = scheduledTx.id + destroy scheduledTx + return <-self.sharedScheduler.borrow()!.cancel(id: id) + } + + /// getTransactionData returns the transaction data for a given ID + /// This function can only get the data for a transaction that is currently scheduled or pending execution + /// because finalized transaction metadata is not stored in the contract + /// @param id: The ID of the transaction to get the data for + /// @return: The transaction data for the given ID + access(all) view fun getTransactionData(id: UInt64): TransactionData? { + return self.sharedScheduler.borrow()!.getTransaction(id: id) + } + + /// borrowHandlerForID returns an un-entitled reference to the transaction handler for a given ID + /// The handler reference can be used to resolve views to get info about the handler and see where it is stored + /// @param id: The ID of the transaction to get the handler for + /// @return: An un-entitled reference to the transaction handler for the given ID + access(all) view fun borrowHandlerForID(_ id: UInt64): &{TransactionHandler}? { + return self.getTransactionData(id: id)?.borrowHandler() + } + + /// getCanceledTransactions returns the IDs of the transactions that have been canceled + /// @return: The IDs of the transactions that have been canceled + access(all) view fun getCanceledTransactions(): [UInt64] { + return self.sharedScheduler.borrow()!.getCanceledTransactions() + } + + + access(all) view fun getStatus(id: UInt64): Status? { + return self.sharedScheduler.borrow()!.getStatus(id: id) + } + + /// getTransactionsForTimeframe returns the IDs of the transactions that are scheduled for a given timeframe + /// @param startTimestamp: The start timestamp to get the IDs for + /// @param endTimestamp: The end timestamp to get the IDs for + /// @return: The IDs of the transactions that are scheduled for the given timeframe + access(all) fun getTransactionsForTimeframe(startTimestamp: UFix64, endTimestamp: UFix64): {UFix64: {UInt8: [UInt64]}} { + return self.sharedScheduler.borrow()!.getTransactionsForTimeframe(startTimestamp: startTimestamp, endTimestamp: endTimestamp) + } + + access(all) view fun getSlotAvailableEffort(timestamp: UFix64, priority: Priority): UInt64 { + // Remove fractional values from the timestamp + let sanitizedTimestamp = UFix64(UInt64(timestamp)) + return self.sharedScheduler.borrow()!.getSlotAvailableEffort(sanitizedTimestamp: sanitizedTimestamp, priority: priority) + } + + access(all) fun getConfig(): {SchedulerConfig} { + return self.sharedScheduler.borrow()!.getConfig() + } + + /// getSizeOfData takes a transaction's data + /// argument and stores it in the contract account's storage, + /// checking storage used before and after to see how large the data is in MB + /// If data is nil, the function returns 0.0 + access(all) fun getSizeOfData(_ data: AnyStruct?): UFix64 { + if data == nil { + return 0.0 + } else { + let type = data!.getType() + if type.isSubtype(of: Type()) + || type.isSubtype(of: Type()) + || type.isSubtype(of: Type
()) + || type.isSubtype(of: Type()) + || type.isSubtype(of: Type()) + { + return 0.0 + } + } + let storagePath = /storage/dataTemp + let storageUsedBefore = self.account.storage.used + self.account.storage.save(data!, to: storagePath) + let storageUsedAfter = self.account.storage.used + self.account.storage.load(from: storagePath) + + return FlowStorageFees.convertUInt64StorageBytesToUFix64Megabytes(storageUsedAfter.saturatingSubtract(storageUsedBefore)) + } + + access(all) init() { + self.storagePath = /storage/sharedScheduler + let scheduler <- create SharedScheduler() + let oldScheduler <- self.account.storage.load<@AnyResource>(from: self.storagePath) + destroy oldScheduler + self.account.storage.save(<-scheduler, to: self.storagePath) + + self.sharedScheduler = self.account.capabilities.storage + .issue(self.storagePath) + } +} \ No newline at end of file diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 0356783d..ad55b0b2 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -348,7 +348,6 @@ access(self) fun _deploy(config: DeploymentConfig) { ) Test.expect(err, Test.beNil()) } - err = Test.deployContract( name: "FlowYieldVaultsClosedBeta", path: "../contracts/FlowYieldVaultsClosedBeta.cdc", @@ -598,6 +597,16 @@ fun positionAvailableBalance( return res.returnValue as! UFix64 } +access(all) +fun resetTransactionScheduler() { + let result = _executeTransaction( + "transactions/reset_scheduler.cdc", + [], + serviceAccount + ) + Test.expect(result, Test.beSucceeded()) +} + /* --- Transaction Helpers --- */ access(all) diff --git a/cadence/tests/transactions/reset_scheduler.cdc b/cadence/tests/transactions/reset_scheduler.cdc new file mode 100644 index 00000000..7087e883 --- /dev/null +++ b/cadence/tests/transactions/reset_scheduler.cdc @@ -0,0 +1,12 @@ +import FlowTransactionScheduler from "MockFlowTransactionScheduler" + +/// Clears all queued/scheduled transactions from the shared scheduler. +transaction { + prepare(signer: auth(BorrowValue) &Account) { + let scheduler = signer.storage.borrow( + from: FlowTransactionScheduler.storagePath + ) ?? panic("Could not borrow SharedScheduler from signer's storage") + + scheduler.reset() + } +} diff --git a/cadence/tests/transactions/set_erc20_balance.cdc b/cadence/tests/transactions/set_erc20_balance.cdc new file mode 100644 index 00000000..24af3423 --- /dev/null +++ b/cadence/tests/transactions/set_erc20_balance.cdc @@ -0,0 +1,36 @@ +import EVM from "MockEVM" + +/// Sets the ERC20 balanceOf for a given holder address via direct storage manipulation. +/// +/// @param tokenAddress: hex EVM address of the ERC20 contract +/// @param holderAddress: hex EVM address whose balance to set +/// @param balanceSlot: the storage slot index of the _balances mapping in the ERC20 contract +/// @param amount: the raw balance value to write (in smallest token units, e.g. satoshis for wBTC) +/// +transaction(tokenAddress: String, holderAddress: String, balanceSlot: UInt256, amount: UInt256) { + prepare(signer: auth(Storage) &Account) { + let token = EVM.addressFromString(tokenAddress) + + var addrHex = holderAddress + if holderAddress.slice(from: 0, upTo: 2) == "0x" { + addrHex = holderAddress.slice(from: 2, upTo: holderAddress.length) + } + let addrBytes = addrHex.decodeHex() + let holder = EVM.EVMAddress(bytes: addrBytes.toConstantSized<[UInt8; 20]>()!) + + let encoded = EVM.encodeABI([holder, balanceSlot]) + let slotHash = String.encodeHex(HashAlgorithm.KECCAK_256.hash(encoded)) + + let raw = amount.toBigEndianBytes() + var padded: [UInt8] = [] + var padCount = 32 - raw.length + while padCount > 0 { + padded.append(0) + padCount = padCount - 1 + } + padded = padded.concat(raw) + let valueHex = String.encodeHex(padded) + + EVM.store(target: token, slot: slotHash, value: valueHex) + } +} diff --git a/cadence/tests/transactions/transfer_wbtc.cdc b/cadence/tests/transactions/transfer_wbtc.cdc new file mode 100644 index 00000000..f3be55aa --- /dev/null +++ b/cadence/tests/transactions/transfer_wbtc.cdc @@ -0,0 +1,30 @@ +import "FungibleToken" + +transaction(recipient: Address, amount: UFix64) { + + let providerVault: auth(FungibleToken.Withdraw) &{FungibleToken.Vault} + let receiver: &{FungibleToken.Receiver} + + let storagePath: StoragePath + let receiverPath: PublicPath + + prepare(signer: auth(BorrowValue) &Account) { + self.storagePath = /storage/EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579Vault + self.receiverPath = /public/EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579Receiver + + self.providerVault = signer.storage.borrow( + from: self.storagePath + ) ?? panic("Could not borrow wBTC vault reference from signer") + + self.receiver = getAccount(recipient).capabilities.borrow<&{FungibleToken.Receiver}>(self.receiverPath) + ?? panic("Could not borrow receiver reference from recipient") + } + + execute { + self.receiver.deposit( + from: <-self.providerVault.withdraw( + amount: amount + ) + ) + } +} diff --git a/flow.json b/flow.json index 13ddca51..034d4bb6 100644 --- a/flow.json +++ b/flow.json @@ -260,6 +260,14 @@ "testing": "0000000000000007" } }, + "MockFlowTransactionScheduler": { + "source": "./cadence/contracts/mocks/FlowTransactionScheduler.cdc", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "e467b9dd11fa00df", + "testnet": "8c5303eaa26202d6" + } + }, "MockEVM": { "source": "./cadence/contracts/mocks/EVM.cdc", "aliases": { From 5e3132120a89be3cbb5c6f9a885d50226d2ef7c4 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 30 Mar 2026 11:03:37 -0700 Subject: [PATCH 2/9] Add Base Case Stress Test forked time series simulation --- .../get_auto_balancer_metrics_by_id.cdc | 16 + .../simulation_base_case_stress_helpers.cdc | 288 ++++++++ .../simulation_base_case_stress_test.cdc | 635 ++++++++++++++++++ cadence/tests/test_helpers.cdc | 83 ++- .../transactions/set_position_health.cdc | 19 + .../set_uniswap_v3_pool_price.cdc | 14 +- 6 files changed, 1046 insertions(+), 9 deletions(-) create mode 100644 cadence/scripts/flow-yield-vaults/get_auto_balancer_metrics_by_id.cdc create mode 100644 cadence/tests/simulation_base_case_stress_helpers.cdc create mode 100644 cadence/tests/simulation_base_case_stress_test.cdc create mode 100644 cadence/tests/transactions/set_position_health.cdc diff --git a/cadence/scripts/flow-yield-vaults/get_auto_balancer_metrics_by_id.cdc b/cadence/scripts/flow-yield-vaults/get_auto_balancer_metrics_by_id.cdc new file mode 100644 index 00000000..59080343 --- /dev/null +++ b/cadence/scripts/flow-yield-vaults/get_auto_balancer_metrics_by_id.cdc @@ -0,0 +1,16 @@ +import "FlowYieldVaultsAutoBalancers" + +/// Returns both currentValue and valueOfDeposits for the AutoBalancer in a single script call. +/// This reduces script call overhead when both values are needed. +/// +/// Returns: [currentValue, valueOfDeposits] or nil if AutoBalancer doesn't exist +/// +access(all) +fun main(id: UInt64): [UFix64]? { + if let autoBalancer = FlowYieldVaultsAutoBalancers.borrowAutoBalancer(id: id) { + let currentValue = autoBalancer.currentValue() ?? 0.0 + let valueOfDeposits = autoBalancer.valueOfDeposits() + return [currentValue, valueOfDeposits] + } + return nil +} diff --git a/cadence/tests/simulation_base_case_stress_helpers.cdc b/cadence/tests/simulation_base_case_stress_helpers.cdc new file mode 100644 index 00000000..02953aff --- /dev/null +++ b/cadence/tests/simulation_base_case_stress_helpers.cdc @@ -0,0 +1,288 @@ +import Test + +// AUTO-GENERATED — do not edit manually +// UnitZero sim: comprehensive_ht_vs_aave_analysis.py +// UnitZero report: High_Tide_vs_AAVE_Executive_Summary_Clean.md +// +// BTC: $100K → $76,342.50 (-23.66%) over 60 minutes +// Exponential decline: price[t] = 100000 * (76342.5/100000)^(t/60) + +access(all) struct SimAgent { + access(all) let count: Int + access(all) let initialHF: UFix64 + access(all) let rebalancingHF: UFix64 + access(all) let targetHF: UFix64 + access(all) let debtPerAgent: UFix64 + access(all) let totalSystemDebt: UFix64 + + init( + count: Int, + initialHF: UFix64, + rebalancingHF: UFix64, + targetHF: UFix64, + debtPerAgent: UFix64, + totalSystemDebt: UFix64 + ) { + self.count = count + self.initialHF = initialHF + self.rebalancingHF = rebalancingHF + self.targetHF = targetHF + self.debtPerAgent = debtPerAgent + self.totalSystemDebt = totalSystemDebt + } +} + +access(all) struct SimPool { + access(all) let size: UFix64 + access(all) let concentration: UFix64 + access(all) let feeTier: UFix64 + + init(size: UFix64, concentration: UFix64, feeTier: UFix64) { + self.size = size + self.concentration = concentration + self.feeTier = feeTier + } +} + +access(all) struct SimConstants { + access(all) let btcCollateralFactor: UFix64 + access(all) let btcLiquidationThreshold: UFix64 + access(all) let yieldAPR: UFix64 + access(all) let directMintYT: Bool + + init( + btcCollateralFactor: UFix64, + btcLiquidationThreshold: UFix64, + yieldAPR: UFix64, + directMintYT: Bool + ) { + self.btcCollateralFactor = btcCollateralFactor + self.btcLiquidationThreshold = btcLiquidationThreshold + self.yieldAPR = yieldAPR + self.directMintYT = directMintYT + } +} + +// ============================================================================ +// SHARED PRICE CURVE — 61 ticks (minute 0..60) +// ============================================================================ + +access(all) let simulation_ht_vs_aave_prices: [UFix64] = [ + 100000.00000000, + 99551.10988532, + 99104.23479398, + 98659.36568076, + 98216.49354101, + 97775.60941052, + 97336.70436530, + 96899.76952145, + 96464.79603492, + 96031.77510137, + 95600.69795598, + 95171.55587329, + 94744.34016698, + 94319.04218975, + 93895.65333310, + 93474.16502717, + 93054.56874058, + 92636.85598024, + 92221.01829119, + 91807.04725642, + 91394.93449671, + 90984.67167043, + 90576.25047343, + 90169.66263880, + 89764.89993677, + 89361.95417450, + 88960.81719592, + 88561.48088159, + 88163.93714849, + 87768.17794992, + 87374.19527526, + 86981.98114989, + 86591.52763495, + 86202.82682725, + 85815.87085904, + 85430.65189793, + 85047.16214665, + 84665.39384295, + 84285.33925943, + 83906.99070337, + 83530.34051657, + 83155.38107523, + 82782.10478976, + 82410.50410463, + 82040.57149825, + 81672.29948276, + 81305.68060395, + 80940.70744104, + 80577.37260658, + 80215.66874628, + 79855.58853885, + 79497.12469588, + 79140.26996166, + 78785.01711307, + 78431.35895940, + 78079.28834222, + 77728.79813524, + 77379.88124414, + 77032.53060649, + 76686.73919150, + 76342.50000000 +] + +// ============================================================================ +// SHARED PROTOCOL CONSTANTS +// ============================================================================ + +access(all) let simulation_ht_vs_aave_constants: SimConstants = SimConstants( + btcCollateralFactor: 0.75000000, + btcLiquidationThreshold: 0.80000000, + yieldAPR: 0.10000000, + directMintYT: true +) + +access(all) let simulation_ht_vs_aave_pools: {String: SimPool} = { + "moet_yt": SimPool( + size: 500000.00000000, + concentration: 0.95000000, + feeTier: 0.00050000 + ), + "moet_btc": SimPool( + size: 500000.00000000, + concentration: 0.80000000, + feeTier: 0.00300000 + ) +} + +access(all) let simulation_ht_vs_aave_durationMinutes: Int = 60 + +// ============================================================================ +// SCENARIO 1: Aggressive (Target HF 1.01) +// ============================================================================ + +access(all) let simulation_aggressive_1_01_agents: [SimAgent] = [ + SimAgent( + count: 5, + initialHF: 1.10000000, + rebalancingHF: 1.05000000, + targetHF: 1.01000000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ), + SimAgent( + count: 5, + initialHF: 1.20000000, + rebalancingHF: 1.05000000, + targetHF: 1.01000000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ) +] + +access(all) let simulation_aggressive_1_01_expectedLiquidationCount: Int = 0 +access(all) let simulation_aggressive_1_01_expectedAllAgentsSurvive: Bool = true + +// ============================================================================ +// SCENARIO 2: Moderate (Target HF 1.025) +// ============================================================================ + +access(all) let simulation_moderate_1_025_agents: [SimAgent] = [ + SimAgent( + count: 5, + initialHF: 1.20000000, + rebalancingHF: 1.05000000, + targetHF: 1.02500000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ), + SimAgent( + count: 5, + initialHF: 1.40000000, + rebalancingHF: 1.05000000, + targetHF: 1.02500000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ) +] + +access(all) let simulation_moderate_1_025_expectedLiquidationCount: Int = 0 +access(all) let simulation_moderate_1_025_expectedAllAgentsSurvive: Bool = true + +// ============================================================================ +// SCENARIO 3: Conservative (Target HF 1.05) +// ============================================================================ + +access(all) let simulation_conservative_1_05_agents: [SimAgent] = [ + SimAgent( + count: 5, + initialHF: 1.30000000, + rebalancingHF: 1.10000000, + targetHF: 1.05000000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ), + SimAgent( + count: 5, + initialHF: 1.50000000, + rebalancingHF: 1.10000000, + targetHF: 1.05000000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ) +] + +access(all) let simulation_conservative_1_05_expectedLiquidationCount: Int = 0 +access(all) let simulation_conservative_1_05_expectedAllAgentsSurvive: Bool = true + +// ============================================================================ +// SCENARIO 4: Mixed (Target HF 1.075) +// ============================================================================ + +access(all) let simulation_mixed_1_075_agents: [SimAgent] = [ + SimAgent( + count: 5, + initialHF: 1.10000000, + rebalancingHF: 1.05000000, + targetHF: 1.07500000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ), + SimAgent( + count: 5, + initialHF: 1.50000000, + rebalancingHF: 1.05000000, + targetHF: 1.07500000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ) +] + +access(all) let simulation_mixed_1_075_expectedLiquidationCount: Int = 0 +access(all) let simulation_mixed_1_075_expectedAllAgentsSurvive: Bool = true + +// ============================================================================ +// SCENARIO 5: Balanced (Target HF 1.1) +// ============================================================================ + +access(all) let simulation_balanced_1_1_agents: [SimAgent] = [ + SimAgent( + count: 5, + initialHF: 1.25000000, + rebalancingHF: 1.10000000, + targetHF: 1.10000000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ), + SimAgent( + count: 5, + initialHF: 1.45000000, + rebalancingHF: 1.10000000, + targetHF: 1.10000000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ) +] + +access(all) let simulation_balanced_1_1_expectedLiquidationCount: Int = 0 +access(all) let simulation_balanced_1_1_expectedAllAgentsSurvive: Bool = true diff --git a/cadence/tests/simulation_base_case_stress_test.cdc b/cadence/tests/simulation_base_case_stress_test.cdc new file mode 100644 index 00000000..e7e6ff24 --- /dev/null +++ b/cadence/tests/simulation_base_case_stress_test.cdc @@ -0,0 +1,635 @@ +#test_fork(network: "mainnet-fork", height: 143292255) + +import Test +import BlockchainHelpers + +import "test_helpers.cdc" +import "evm_state_helpers.cdc" +import "simulation_base_case_stress_helpers.cdc" + +import "FlowYieldVaults" +import "FlowToken" +import "MOET" +import "FlowYieldVaultsStrategiesV2" +import "FlowALPv0" +import "DeFiActions" + +// ============================================================================ +// CADENCE ACCOUNTS +// ============================================================================ + +access(all) let flowYieldVaultsAccount = Test.getAccount(0xb1d63873c3cc9f79) +access(all) let flowALPAccount = Test.getAccount(0x6b00ff876c299c61) +access(all) let bandOracleAccount = Test.getAccount(0x6801a6222ebf784a) +access(all) let whaleFlowAccount = Test.getAccount(0x92674150c9213fc9) +access(all) let coaOwnerAccount = Test.getAccount(0xe467b9dd11fa00df) + +// WBTC on Flow EVM +access(all) let WBTC_TOKEN_ID = "A.1e4aa0b87d10b141.EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579.Vault" +access(all) let WBTC_TYPE = CompositeType(WBTC_TOKEN_ID)! + +access(all) var strategyIdentifier = Type<@FlowYieldVaultsStrategiesV2.FUSDEVStrategy>().identifier +access(all) var wbtcTokenIdentifier = WBTC_TOKEN_ID + +// ============================================================================ +// PROTOCOL ADDRESSES +// ============================================================================ + +access(all) let factoryAddress = "0xca6d7Bb03334bBf135902e1d919a5feccb461632" + +// ============================================================================ +// VAULT & TOKEN ADDRESSES +// ============================================================================ + +access(all) let morphoVaultAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" +access(all) let pyusd0Address = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" +access(all) let moetAddress = "0x213979bB8A9A86966999b3AA797C1fcf3B967ae2" +access(all) let wbtcAddress = "0x717dae2baf7656be9a9b01dee31d571a9d4c9579" + +// ============================================================================ +// STORAGE SLOT CONSTANTS +// ============================================================================ + +access(all) let moetBalanceSlot = 0 as UInt256 +access(all) let pyusd0BalanceSlot = 1 as UInt256 +access(all) let fusdevBalanceSlot = 12 as UInt256 +access(all) let wbtcBalanceSlot = 5 as UInt256 + +access(all) let morphoVaultTotalSupplySlot = 11 as UInt256 +access(all) let morphoVaultTotalAssetsSlot = 15 as UInt256 + +// ============================================================================ +// SIMULATION TYPES +// ============================================================================ + +access(all) struct SimConfig { + access(all) let prices: [UFix64] + access(all) let tickIntervalSeconds: UFix64 + access(all) let numAgents: Int + access(all) let fundingPerAgent: UFix64 + access(all) let yieldAPR: UFix64 + access(all) let expectedLiquidationCount: Int + /// How often (in ticks) to attempt rebalancing. + /// 1 = rebalance every tick (default) + access(all) let rebalanceInterval: Int + /// Position health thresholds + access(all) let minHealth: UFix64 + access(all) let targetHealth: UFix64 + access(all) let maxHealth: UFix64 + /// Initial effective HF to coerce the position to at creation (matches Python initial_hf) + access(all) let initialHF: UFix64 + + init( + prices: [UFix64], + tickIntervalSeconds: UFix64, + numAgents: Int, + fundingPerAgent: UFix64, + yieldAPR: UFix64, + expectedLiquidationCount: Int, + rebalanceInterval: Int, + minHealth: UFix64, + targetHealth: UFix64, + maxHealth: UFix64, + initialHF: UFix64 + ) { + self.prices = prices + self.tickIntervalSeconds = tickIntervalSeconds + self.numAgents = numAgents + self.fundingPerAgent = fundingPerAgent + self.yieldAPR = yieldAPR + self.expectedLiquidationCount = expectedLiquidationCount + self.rebalanceInterval = rebalanceInterval + self.minHealth = minHealth + self.targetHealth = targetHealth + self.maxHealth = maxHealth + self.initialHF = initialHF + } +} + +access(all) struct SimResult { + access(all) let rebalanceCount: Int + access(all) let liquidationCount: Int + access(all) let lowestHF: UFix64 + access(all) let finalHF: UFix64 + access(all) let lowestPrice: UFix64 + access(all) let finalPrice: UFix64 + + init( + rebalanceCount: Int, + liquidationCount: Int, + lowestHF: UFix64, + finalHF: UFix64, + lowestPrice: UFix64, + finalPrice: UFix64 + ) { + self.rebalanceCount = rebalanceCount + self.liquidationCount = liquidationCount + self.lowestHF = lowestHF + self.finalHF = finalHF + self.lowestPrice = lowestPrice + self.finalPrice = finalPrice + } +} + +// ============================================================================ +// SETUP +// ============================================================================ + +access(all) +fun setup() { + deployContractsForFork() + + // PYUSD0:morphoVault (routing pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: 1.0, + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + // MOET:morphoVault (yield token pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: 1.0, + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + // MOET:PYUSD0 (routing pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: pyusd0Address, + fee: 100, + priceTokenBPerTokenA: 1.0, + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // PYUSD0:WBTC (collateral/liquidation pool) — infinite liquidity for now + let initialBtcPrice = simulation_ht_vs_aave_prices[0] + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wbtcAddress, + tokenBAddress: pyusd0Address, + fee: 3000, + priceTokenBPerTokenA: UFix128(initialBtcPrice), + tokenABalanceSlot: wbtcBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { + "BTC": initialBtcPrice, + "USD": 1.0 + }) + + let reserveAmount = 100_000_00.0 + transferFlow(signer: whaleFlowAccount, recipient: flowALPAccount.address, amount: reserveAmount) + mintMoet(signer: flowALPAccount, to: flowALPAccount.address, amount: reserveAmount, beFailed: false) + + transferFlow(signer: whaleFlowAccount, recipient: flowYieldVaultsAccount.address, amount: reserveAmount) + transferFlow(signer: whaleFlowAccount, recipient: coaOwnerAccount.address, amount: reserveAmount) +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +access(all) fun getBTCCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == WBTC_TYPE { + if balance.direction == FlowALPv0.BalanceDirection.Credit { + return balance.balance + } + } + } + return 0.0 +} + +/// Compute deterministic YT (ERC4626 vault share) price at a given tick. +/// price = 1.0 + yieldAPR * (seconds / secondsPerYear) +access(all) fun ytPriceAtTick(_ tick: Int, tickIntervalSeconds: UFix64, yieldAPR: UFix64): UFix64 { + let secondsPerYear: UFix64 = 31536000.0 + let elapsedSeconds = UFix64(tick) * tickIntervalSeconds + return 1.0 + yieldAPR * (elapsedSeconds / secondsPerYear) +} + +/// Update all prices for a given simulation tick. +access(all) fun applyPriceTick(btcPrice: UFix64, ytPrice: UFix64, signer: Test.TestAccount) { + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { + "BTC": btcPrice, + "USD": 1.0 + }) + + // PYUSD0:WBTC pool — update BTC price + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wbtcAddress, + tokenBAddress: pyusd0Address, + fee: 3000, + priceTokenBPerTokenA: UFix128(btcPrice), + tokenABalanceSlot: wbtcBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: coaOwnerAccount + ) + + // MOET:FUSDEV pool — update YT price + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: UFix128(ytPrice), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: ytPrice, + signer: signer + ) +} + +// ============================================================================ +// SIMULATION RUNNER +// ============================================================================ + +access(all) fun runSimulation(config: SimConfig, label: String): SimResult { + let prices = config.prices + let initialPrice = prices[0] + + // Clear scheduled transactions inherited from forked mainnet state + resetTransactionScheduler() + + // Apply initial pricing + applyPriceTick(btcPrice: initialPrice, ytPrice: ytPriceAtTick(0, tickIntervalSeconds: config.tickIntervalSeconds, yieldAPR: config.yieldAPR), signer: coaOwnerAccount) + + // Create agents + let users: [Test.TestAccount] = [] + let pids: [UInt64] = [] + let vaultIds: [UInt64] = [] + + var i = 0 + while i < config.numAgents { + let user = Test.createAccount() + transferFlow(signer: whaleFlowAccount, recipient: user.address, amount: 10.0) + mintBTC(signer: user, amount: config.fundingPerAgent) + grantBeta(flowYieldVaultsAccount, user) + + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: 1.0, + signer: user + ) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: wbtcTokenIdentifier, + amount: config.fundingPerAgent, + beFailed: false + ) + + let pid = (getLastPositionOpenedEvent(Test.eventsOfType(Type())) as! FlowALPv0.Opened).pid + let yieldVaultIDs = getYieldVaultIDs(address: user.address)! + let vaultId = yieldVaultIDs[0] + + // Step 1: Coerce position to the desired initial HF. + // Set temporary health params with targetHealth=initialHF, then force-rebalance. + // This makes the on-chain rebalancer push the position to exactly initialHF. + setPositionHealth( + signer: flowALPAccount, + pid: pid, + minHealth: config.initialHF - 0.01, + targetHealth: config.initialHF, + maxHealth: config.initialHF + 0.01 + ) + rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) + + // Step 2: Set the real health thresholds for the simulation. + setPositionHealth( + signer: flowALPAccount, + pid: pid, + minHealth: config.minHealth, + targetHealth: config.targetHealth, + maxHealth: config.maxHealth + ) + + users.append(user) + pids.append(pid) + vaultIds.append(vaultId) + + log(" Agent \(i): pid=\(pid) vaultId=\(vaultId)") + i = i + 1 + } + + log("\n=== SIMULATION: \(label) ===") + log("Agents: \(config.numAgents)") + log("Funding per agent: \(config.fundingPerAgent) BTC (~\(config.fundingPerAgent * initialPrice) MOET)") + log("Tick interval: \(config.tickIntervalSeconds)s") + log("Price points: \(prices.length)") + log("Initial BTC price: $\(prices[0])") + log("Initial HF: \(config.initialHF)") + log("") + log("Rebalance Triggers:") + log(" HF (Position): triggers when HF < \(config.minHealth), rebalances to HF = \(config.targetHealth)") + log(" Liquidation: HF < 1.0 (on-chain effectiveCollateral/effectiveDebt)") + log("Notes: BTC $100K -> $76,342.50 (-23.66%) over 60 minutes") + + var liquidationCount = 0 + var previousBTCPrice = initialPrice + var lowestPrice = initialPrice + var highestPrice = initialPrice + var lowestHF = 100.0 + var prevVaultRebalanceCount = 0 + var prevPositionRebalanceCount = 0 + + let startTimestamp = getCurrentBlockTimestamp() + + var step = 0 + while step < prices.length { + let absolutePrice = prices[step] + let ytPrice = ytPriceAtTick(step, tickIntervalSeconds: config.tickIntervalSeconds, yieldAPR: config.yieldAPR) + + if absolutePrice < lowestPrice { + lowestPrice = absolutePrice + } + if absolutePrice > highestPrice { + highestPrice = absolutePrice + } + + if absolutePrice != previousBTCPrice { + let expectedTimestamp = startTimestamp + UFix64(step) * config.tickIntervalSeconds + let currentTimestamp = getCurrentBlockTimestamp() + if expectedTimestamp > currentTimestamp { + Test.moveTime(by: Fix64(expectedTimestamp - currentTimestamp)) + } + + applyPriceTick(btcPrice: absolutePrice, ytPrice: ytPrice, signer: users[0]) + + // Calculate HF BEFORE rebalancing + var preRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) + + // Rebalance agents on interval + if config.rebalanceInterval <= 1 || step % config.rebalanceInterval == 0 { + var a = 0 + while a < config.numAgents { + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: vaultIds[a], force: false, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pids[a], force: false, beFailed: false) + a = a + 1 + } + } + + // Count actual rebalances that occurred this tick + let currentVaultRebalanceCount = Test.eventsOfType(Type()).length + let currentPositionRebalanceCount = Test.eventsOfType(Type()).length + let tickVaultRebalances = currentVaultRebalanceCount - prevVaultRebalanceCount + let tickPositionRebalances = currentPositionRebalanceCount - prevPositionRebalanceCount + prevVaultRebalanceCount = currentVaultRebalanceCount + prevPositionRebalanceCount = currentPositionRebalanceCount + + // Calculate HF AFTER rebalancing + var postRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) + + // Track lowest HF (use pre-rebalance to capture the actual low point) + if preRebalanceHF < lowestHF && preRebalanceHF > 0.0 { + lowestHF = preRebalanceHF + } + + // Log every tick with pre→post HF + log(" [t=\(step)] price=$\(absolutePrice) yt=\(ytPrice) HF=\(preRebalanceHF)->\(postRebalanceHF) vaultRebalances=\(tickVaultRebalances) positionRebalances=\(tickPositionRebalances)") + + // Liquidation check (pre-rebalance HF is the danger point) + if preRebalanceHF < 1.0 && preRebalanceHF > 0.0 { + liquidationCount = liquidationCount + 1 + log(" *** LIQUIDATION agent=0 at t=\(step)! HF=\(preRebalanceHF) ***") + } + + previousBTCPrice = absolutePrice + } + + step = step + 1 + } + + // Count actual rebalance events (not just attempts) + let vaultRebalanceCount = Test.eventsOfType(Type()).length + let positionRebalanceCount = Test.eventsOfType(Type()).length + + // Final state + let finalHF = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) + let finalBTCCollateral = getBTCCollateralFromPosition(pid: pids[0]) + let finalDebt = getMOETDebtFromPosition(pid: pids[0]) + let finalYieldTokens = getAutoBalancerBalance(id: vaultIds[0])! + let finalYtPrice = ytPriceAtTick(prices.length - 1, tickIntervalSeconds: config.tickIntervalSeconds, yieldAPR: config.yieldAPR) + let finalPrice = prices[prices.length - 1] + let collateralValueMOET = finalBTCCollateral * previousBTCPrice + let ytValueMOET = finalYieldTokens * finalYtPrice + + log("\n=== SIMULATION RESULTS ===") + log("Agents: \(config.numAgents)") + log("Rebalance attempts: \(prices.length * config.numAgents)") + log("Vault rebalances: \(vaultRebalanceCount)") + log("Position rebalances: \(positionRebalanceCount)") + log("Liquidation count: \(liquidationCount)") + log("") + log("--- Price ---") + log("Initial BTC price: $\(initialPrice)") + log("Lowest BTC price: $\(lowestPrice)") + log("Highest BTC price: $\(highestPrice)") + log("Final BTC price: $\(finalPrice)") + log("") + log("--- Position ---") + log("Initial HF: \(config.initialHF)") + log("Lowest HF observed: \(lowestHF)") + log("Final HF (agent 0): \(finalHF)") + log("Final collateral: \(finalBTCCollateral) BTC (value: \(collateralValueMOET) MOET)") + log("Final debt: \(finalDebt) MOET") + log("Final yield tokens: \(finalYieldTokens) (value: \(ytValueMOET) MOET @ yt=\(finalYtPrice))") + log("===========================\n") + + return SimResult( + rebalanceCount: positionRebalanceCount, + liquidationCount: liquidationCount, + lowestHF: lowestHF, + finalHF: finalHF, + lowestPrice: lowestPrice, + finalPrice: finalPrice + ) +} + +// ============================================================================ +// TEST: Aggressive_1.01 — Initial HF 1.1–1.2, Target HF 1.01 +// ============================================================================ + +access(all) +fun test_Aggressive_1_01_ZeroLiquidations() { + // Python: rebalancingHF=targetHF=1.01, initialHF=1.1-1.2 + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.01, + targetHealth: 1.01000001, + maxHealth: 1.5, + initialHF: 1.15 // midpoint of Python initial_hf_range (1.1, 1.2) + ), + label: "Aggressive_1.01" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + + log("=== TEST PASSED: Aggressive_1.01 — Zero liquidations under 23.66% BTC crash ===") +} + +// ============================================================================ +// TEST: Balanced_1.1 — Initial HF 1.25–1.45, Target HF 1.1 +// ============================================================================ + +access(all) +fun test_Balanced_1_1_ZeroLiquidations() { + // Python: rebalancingHF=targetHF=1.10, initialHF=1.25-1.45 + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.1, + targetHealth: 1.10000001, + maxHealth: 1.5, + initialHF: 1.35 // midpoint of Python initial_hf_range (1.25, 1.45) + ), + label: "Balanced_1.1" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + + log("=== TEST PASSED: Balanced_1.1 — Zero liquidations under 23.66% BTC crash ===") +} + +// ============================================================================ +// TEST: Conservative_1.05 — Initial HF 1.3–1.5, Target HF 1.05 +// ============================================================================ + +access(all) +fun test_Conservative_1_05_ZeroLiquidations() { + // Python: rebalancingHF=targetHF=1.05, initialHF=1.3-1.5 + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.05, + targetHealth: 1.05000001, + maxHealth: 1.5, + initialHF: 1.4 // midpoint of Python initial_hf_range (1.3, 1.5) + ), + label: "Conservative_1.05" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + + log("=== TEST PASSED: Conservative_1.05 — Zero liquidations under 23.66% BTC crash ===") +} + +// ============================================================================ +// TEST: Mixed_1.075 — Initial HF 1.1–1.5, Target HF 1.075 +// ============================================================================ + +access(all) +fun test_Mixed_1_075_ZeroLiquidations() { + // Python: rebalancingHF=targetHF=1.075, initialHF=1.1-1.5 + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.075, + targetHealth: 1.07500001, + maxHealth: 1.5, + initialHF: 1.3 // midpoint of Python initial_hf_range (1.1, 1.5) + ), + label: "Mixed_1.075" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + + log("=== TEST PASSED: Mixed_1.075 — Zero liquidations under 23.66% BTC crash ===") +} + +// ============================================================================ +// TEST: Moderate_1.025 — Initial HF 1.2–1.4, Target HF 1.025 +// ============================================================================ + +access(all) +fun test_Moderate_1_025_ZeroLiquidations() { + // Python: rebalancingHF=targetHF=1.025, initialHF=1.2-1.4 + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.025, + targetHealth: 1.02500001, + maxHealth: 1.5, + initialHF: 1.3 // midpoint of Python initial_hf_range (1.2, 1.4) + ), + label: "Moderate_1.025" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + + log("=== TEST PASSED: Moderate_1.025 — Zero liquidations under 23.66% BTC crash ===") +} + diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index ad55b0b2..1835ecbb 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -226,7 +226,10 @@ access(all) fun deployContractsForFork() { // Deploy EVM mock var err = Test.deployContract(name: "EVM", path: "../contracts/mocks/EVM.cdc", arguments: []) - + + // Redeploy FlowTransactionScheduler mock (replaces forked mainnet contract with reset-capable version) + err = Test.deployContract(name: "FlowTransactionScheduler", path: "../contracts/mocks/FlowTransactionScheduler.cdc", arguments: []) + _deploy(config: config) } @@ -607,6 +610,18 @@ fun resetTransactionScheduler() { Test.expect(result, Test.beSucceeded()) } +/// Set position health thresholds (minHealth, targetHealth, maxHealth) on an existing position. +/// This borrows the Pool from the flowALP account and directly modifies the InternalPosition. +access(all) +fun setPositionHealth(signer: Test.TestAccount, pid: UInt64, minHealth: UFix64, targetHealth: UFix64, maxHealth: UFix64) { + let result = _executeTransaction( + "transactions/set_position_health.cdc", + [pid, minHealth, targetHealth, maxHealth], + signer + ) + Test.expect(result, Test.beSucceeded()) +} + /* --- Transaction Helpers --- */ access(all) @@ -788,8 +803,11 @@ fun setBandOraclePrices(signer: Test.TestAccount, symbolPrices: {String: UFix64} for symbol in symbolPrices.keys { // BandOracle uses 1e9 multiplier for prices // e.g., $1.00 = 1_000_000_000, $0.50 = 500_000_000 + // Split into whole + fractional to avoid UFix64 overflow for large prices (e.g. BTC > $184) let price = symbolPrices[symbol]! - symbolsRates[symbol] = UInt64(price * 1_000_000_000.0) + let whole = UInt64(price) + let frac = price - UFix64(whole) + symbolsRates[symbol] = whole * 1_000_000_000 + UInt64(frac * 1_000_000_000.0) } let setRes = _executeTransaction( @@ -1121,4 +1139,65 @@ access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { } } return 0.0 +} + +access(all) +fun setupGenericVault(signer: Test.TestAccount, vaultIdentifier: String) { + let setupResult = _executeTransaction( + "../../lib/flow-evm-bridge/cadence/transactions/example-assets/setup/setup_generic_vault.cdc", + [vaultIdentifier], + signer + ) + Test.expect(setupResult, Test.beSucceeded()) +} + +access(all) +fun setERC20Balance( + signer: Test.TestAccount, + tokenAddress: String, + holderAddress: String, + balanceSlot: UInt256, + amount: UInt256 +) { + let res = _executeTransaction( + "transactions/set_erc20_balance.cdc", + [tokenAddress, holderAddress, balanceSlot, amount], + signer + ) + Test.expect(res, Test.beSucceeded()) +} + +access(all) +fun mintBTC(signer: Test.TestAccount, amount: UFix64) { + let wbtcAddress = "0x717dae2baf7656be9a9b01dee31d571a9d4c9579" + let wbtcTokenId = "A.1e4aa0b87d10b141.EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579.Vault" + let wbtcBalanceSlot: UInt256 = 5 + + // Ensure signer has a COA (needs some FLOW for gas) + if getCOA(signer.address) == nil { + createCOA(signer, fundingAmount: 1.0) + } + let coaAddress = getCOA(signer.address)! + + // Set wBTC ERC20 balance for the signer's COA on EVM + // wBTC has 8 decimals, so multiply amount by 1e8 + // Split to avoid UFix64 overflow for large amounts + let whole = UInt256(amount) + let frac = amount - UFix64(UInt64(amount)) + let amountSmallestUnit = whole * 100_000_000 + UInt256(frac * 100_000_000.0) + setERC20Balance( + signer: signer, + tokenAddress: wbtcAddress, + holderAddress: coaAddress, + balanceSlot: wbtcBalanceSlot, + amount: amountSmallestUnit + ) + + // Bridge wBTC from EVM to Cadence + let bridgeRes = _executeTransaction( + "../../lib/flow-evm-bridge/cadence/transactions/bridge/tokens/bridge_tokens_from_evm.cdc", + [wbtcTokenId, amountSmallestUnit], + signer + ) + Test.expect(bridgeRes, Test.beSucceeded()) } \ No newline at end of file diff --git a/cadence/tests/transactions/set_position_health.cdc b/cadence/tests/transactions/set_position_health.cdc new file mode 100644 index 00000000..8ce62303 --- /dev/null +++ b/cadence/tests/transactions/set_position_health.cdc @@ -0,0 +1,19 @@ +import FlowALPv0 from "FlowALPv0" + +transaction(pid: UInt64, minHealth: UFix64, targetHealth: UFix64, maxHealth: UFix64) { + prepare(signer: auth(BorrowValue) &Account) { + let pool = signer.storage.borrow( + from: FlowALPv0.PoolStoragePath + ) ?? panic("Could not borrow Pool") + + let position = pool.borrowPosition(pid: pid) + + // Each setter enforces minHealth < targetHealth < maxHealth independently, + // so we must widen the range before narrowing to avoid intermediate violations. + position.setMinHealth(1.00000001) + position.setMaxHealth(UFix128.max) + position.setTargetHealth(UFix128(targetHealth)) + position.setMinHealth(UFix128(minHealth)) + position.setMaxHealth(UFix128(maxHealth)) + } +} diff --git a/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc index 6b4b1b0a..819c31fe 100644 --- a/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc +++ b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc @@ -201,22 +201,22 @@ transaction( assert(slot0Value.length == 64, message: "slot0 must be 64 hex chars") // --- Slot 0: slot0 (packed) --- - EVM.store(target: poolAddr, slot: "0", value: slot0Value) + EVM.store(target: poolAddr, slot: slotHex(0), value: slot0Value) // Verify round-trip - let readBack = EVM.load(target: poolAddr, slot: "0") + let readBack = EVM.load(target: poolAddr, slot: slotHex(0)) let readBackHex = String.encodeHex(readBack) assert(readBackHex == slot0Value, message: "slot0 read-back mismatch - storage corruption!") // --- Slots 1-3: feeGrowthGlobal0X128, feeGrowthGlobal1X128, protocolFees = 0 --- let zero32 = "0000000000000000000000000000000000000000000000000000000000000000" - EVM.store(target: poolAddr, slot: "1", value: zero32) - EVM.store(target: poolAddr, slot: "2", value: zero32) - EVM.store(target: poolAddr, slot: "3", value: zero32) + EVM.store(target: poolAddr, slot: slotHex(1), value: zero32) + EVM.store(target: poolAddr, slot: slotHex(2), value: zero32) + EVM.store(target: poolAddr, slot: slotHex(3), value: zero32) // --- Slot 4: liquidity = uint128 max --- let liquidityAmount: UInt256 = 340282366920938463463374607431768211455 // 2^128 - 1 - EVM.store(target: poolAddr, slot: "4", value: toHex32(liquidityAmount)) + EVM.store(target: poolAddr, slot: slotHex(4), value: toHex32(liquidityAmount)) // --- Initialize boundary ticks --- // Tick storage layout per tick (4 consecutive slots): @@ -293,7 +293,7 @@ transaction( assert(obs0Bytes.length == 32, message: "observations[0] must be exactly 32 bytes") - EVM.store(target: poolAddr, slot: "8", value: String.encodeHex(obs0Bytes)) + EVM.store(target: poolAddr, slot: slotHex(8), value: String.encodeHex(obs0Bytes)) // --- Fund pool with token balances --- // Calculate 1 billion tokens in each token's decimal format From 707675bf0871e0a428f939f549ab158ab8c355cd Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 30 Mar 2026 17:09:29 -0700 Subject: [PATCH 3/9] Add missing helpers --- .../get_auto_balancer_metrics_by_id.cdc | 16 ++++ cadence/tests/test_helpers.cdc | 83 ++++++++++++++++++- .../transactions/set_position_health.cdc | 19 +++++ .../set_uniswap_v3_pool_price.cdc | 14 ++-- 4 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 cadence/scripts/flow-yield-vaults/get_auto_balancer_metrics_by_id.cdc create mode 100644 cadence/tests/transactions/set_position_health.cdc diff --git a/cadence/scripts/flow-yield-vaults/get_auto_balancer_metrics_by_id.cdc b/cadence/scripts/flow-yield-vaults/get_auto_balancer_metrics_by_id.cdc new file mode 100644 index 00000000..59080343 --- /dev/null +++ b/cadence/scripts/flow-yield-vaults/get_auto_balancer_metrics_by_id.cdc @@ -0,0 +1,16 @@ +import "FlowYieldVaultsAutoBalancers" + +/// Returns both currentValue and valueOfDeposits for the AutoBalancer in a single script call. +/// This reduces script call overhead when both values are needed. +/// +/// Returns: [currentValue, valueOfDeposits] or nil if AutoBalancer doesn't exist +/// +access(all) +fun main(id: UInt64): [UFix64]? { + if let autoBalancer = FlowYieldVaultsAutoBalancers.borrowAutoBalancer(id: id) { + let currentValue = autoBalancer.currentValue() ?? 0.0 + let valueOfDeposits = autoBalancer.valueOfDeposits() + return [currentValue, valueOfDeposits] + } + return nil +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index ad55b0b2..1835ecbb 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -226,7 +226,10 @@ access(all) fun deployContractsForFork() { // Deploy EVM mock var err = Test.deployContract(name: "EVM", path: "../contracts/mocks/EVM.cdc", arguments: []) - + + // Redeploy FlowTransactionScheduler mock (replaces forked mainnet contract with reset-capable version) + err = Test.deployContract(name: "FlowTransactionScheduler", path: "../contracts/mocks/FlowTransactionScheduler.cdc", arguments: []) + _deploy(config: config) } @@ -607,6 +610,18 @@ fun resetTransactionScheduler() { Test.expect(result, Test.beSucceeded()) } +/// Set position health thresholds (minHealth, targetHealth, maxHealth) on an existing position. +/// This borrows the Pool from the flowALP account and directly modifies the InternalPosition. +access(all) +fun setPositionHealth(signer: Test.TestAccount, pid: UInt64, minHealth: UFix64, targetHealth: UFix64, maxHealth: UFix64) { + let result = _executeTransaction( + "transactions/set_position_health.cdc", + [pid, minHealth, targetHealth, maxHealth], + signer + ) + Test.expect(result, Test.beSucceeded()) +} + /* --- Transaction Helpers --- */ access(all) @@ -788,8 +803,11 @@ fun setBandOraclePrices(signer: Test.TestAccount, symbolPrices: {String: UFix64} for symbol in symbolPrices.keys { // BandOracle uses 1e9 multiplier for prices // e.g., $1.00 = 1_000_000_000, $0.50 = 500_000_000 + // Split into whole + fractional to avoid UFix64 overflow for large prices (e.g. BTC > $184) let price = symbolPrices[symbol]! - symbolsRates[symbol] = UInt64(price * 1_000_000_000.0) + let whole = UInt64(price) + let frac = price - UFix64(whole) + symbolsRates[symbol] = whole * 1_000_000_000 + UInt64(frac * 1_000_000_000.0) } let setRes = _executeTransaction( @@ -1121,4 +1139,65 @@ access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { } } return 0.0 +} + +access(all) +fun setupGenericVault(signer: Test.TestAccount, vaultIdentifier: String) { + let setupResult = _executeTransaction( + "../../lib/flow-evm-bridge/cadence/transactions/example-assets/setup/setup_generic_vault.cdc", + [vaultIdentifier], + signer + ) + Test.expect(setupResult, Test.beSucceeded()) +} + +access(all) +fun setERC20Balance( + signer: Test.TestAccount, + tokenAddress: String, + holderAddress: String, + balanceSlot: UInt256, + amount: UInt256 +) { + let res = _executeTransaction( + "transactions/set_erc20_balance.cdc", + [tokenAddress, holderAddress, balanceSlot, amount], + signer + ) + Test.expect(res, Test.beSucceeded()) +} + +access(all) +fun mintBTC(signer: Test.TestAccount, amount: UFix64) { + let wbtcAddress = "0x717dae2baf7656be9a9b01dee31d571a9d4c9579" + let wbtcTokenId = "A.1e4aa0b87d10b141.EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579.Vault" + let wbtcBalanceSlot: UInt256 = 5 + + // Ensure signer has a COA (needs some FLOW for gas) + if getCOA(signer.address) == nil { + createCOA(signer, fundingAmount: 1.0) + } + let coaAddress = getCOA(signer.address)! + + // Set wBTC ERC20 balance for the signer's COA on EVM + // wBTC has 8 decimals, so multiply amount by 1e8 + // Split to avoid UFix64 overflow for large amounts + let whole = UInt256(amount) + let frac = amount - UFix64(UInt64(amount)) + let amountSmallestUnit = whole * 100_000_000 + UInt256(frac * 100_000_000.0) + setERC20Balance( + signer: signer, + tokenAddress: wbtcAddress, + holderAddress: coaAddress, + balanceSlot: wbtcBalanceSlot, + amount: amountSmallestUnit + ) + + // Bridge wBTC from EVM to Cadence + let bridgeRes = _executeTransaction( + "../../lib/flow-evm-bridge/cadence/transactions/bridge/tokens/bridge_tokens_from_evm.cdc", + [wbtcTokenId, amountSmallestUnit], + signer + ) + Test.expect(bridgeRes, Test.beSucceeded()) } \ No newline at end of file diff --git a/cadence/tests/transactions/set_position_health.cdc b/cadence/tests/transactions/set_position_health.cdc new file mode 100644 index 00000000..8ce62303 --- /dev/null +++ b/cadence/tests/transactions/set_position_health.cdc @@ -0,0 +1,19 @@ +import FlowALPv0 from "FlowALPv0" + +transaction(pid: UInt64, minHealth: UFix64, targetHealth: UFix64, maxHealth: UFix64) { + prepare(signer: auth(BorrowValue) &Account) { + let pool = signer.storage.borrow( + from: FlowALPv0.PoolStoragePath + ) ?? panic("Could not borrow Pool") + + let position = pool.borrowPosition(pid: pid) + + // Each setter enforces minHealth < targetHealth < maxHealth independently, + // so we must widen the range before narrowing to avoid intermediate violations. + position.setMinHealth(1.00000001) + position.setMaxHealth(UFix128.max) + position.setTargetHealth(UFix128(targetHealth)) + position.setMinHealth(UFix128(minHealth)) + position.setMaxHealth(UFix128(maxHealth)) + } +} diff --git a/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc index 6b4b1b0a..819c31fe 100644 --- a/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc +++ b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc @@ -201,22 +201,22 @@ transaction( assert(slot0Value.length == 64, message: "slot0 must be 64 hex chars") // --- Slot 0: slot0 (packed) --- - EVM.store(target: poolAddr, slot: "0", value: slot0Value) + EVM.store(target: poolAddr, slot: slotHex(0), value: slot0Value) // Verify round-trip - let readBack = EVM.load(target: poolAddr, slot: "0") + let readBack = EVM.load(target: poolAddr, slot: slotHex(0)) let readBackHex = String.encodeHex(readBack) assert(readBackHex == slot0Value, message: "slot0 read-back mismatch - storage corruption!") // --- Slots 1-3: feeGrowthGlobal0X128, feeGrowthGlobal1X128, protocolFees = 0 --- let zero32 = "0000000000000000000000000000000000000000000000000000000000000000" - EVM.store(target: poolAddr, slot: "1", value: zero32) - EVM.store(target: poolAddr, slot: "2", value: zero32) - EVM.store(target: poolAddr, slot: "3", value: zero32) + EVM.store(target: poolAddr, slot: slotHex(1), value: zero32) + EVM.store(target: poolAddr, slot: slotHex(2), value: zero32) + EVM.store(target: poolAddr, slot: slotHex(3), value: zero32) // --- Slot 4: liquidity = uint128 max --- let liquidityAmount: UInt256 = 340282366920938463463374607431768211455 // 2^128 - 1 - EVM.store(target: poolAddr, slot: "4", value: toHex32(liquidityAmount)) + EVM.store(target: poolAddr, slot: slotHex(4), value: toHex32(liquidityAmount)) // --- Initialize boundary ticks --- // Tick storage layout per tick (4 consecutive slots): @@ -293,7 +293,7 @@ transaction( assert(obs0Bytes.length == 32, message: "observations[0] must be exactly 32 bytes") - EVM.store(target: poolAddr, slot: "8", value: String.encodeHex(obs0Bytes)) + EVM.store(target: poolAddr, slot: slotHex(8), value: String.encodeHex(obs0Bytes)) // --- Fund pool with token balances --- // Calculate 1 billion tokens in each token's decimal format From a167f73203162673e4c97123af3522ac34e7a0dc Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 30 Mar 2026 17:12:34 -0700 Subject: [PATCH 4/9] Remove `_test` suffix to avoid CI runs --- ..._base_case_stress_test.cdc => simulation_base_case_stress.cdc} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cadence/tests/{simulation_base_case_stress_test.cdc => simulation_base_case_stress.cdc} (100%) diff --git a/cadence/tests/simulation_base_case_stress_test.cdc b/cadence/tests/simulation_base_case_stress.cdc similarity index 100% rename from cadence/tests/simulation_base_case_stress_test.cdc rename to cadence/tests/simulation_base_case_stress.cdc From 224ef61b5455b9d19d087ecc3eb7b5599b90df48 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 1 Apr 2026 16:45:13 -0700 Subject: [PATCH 5/9] Add comment for the FlowTransactionScheduler.cdc mock --- cadence/contracts/mocks/FlowTransactionScheduler.cdc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cadence/contracts/mocks/FlowTransactionScheduler.cdc b/cadence/contracts/mocks/FlowTransactionScheduler.cdc index 642b938f..8bc0c351 100644 --- a/cadence/contracts/mocks/FlowTransactionScheduler.cdc +++ b/cadence/contracts/mocks/FlowTransactionScheduler.cdc @@ -1293,6 +1293,12 @@ access(all) contract FlowTransactionScheduler { transactionHandler.executeTransaction(id: id, data: tx.getData()) } + // ===================================================================== + // ADDED FOR FORK TESTS — not present in the production contract. + // Forked mainnet state contains pre-existing scheduled transactions + // that interfere with simulation setup. This method clears them so + // each test starts from a clean scheduler state. + // ===================================================================== /// Clears all queued/scheduled transactions, resetting the scheduler to an empty state. access(Cancel) fun reset() { self.transactions = {} From 539f8ba9b209943254b724e29d1e90cbaa971b5a Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 1 Apr 2026 16:45:29 -0700 Subject: [PATCH 6/9] Add pool liquidity & concentration value --- cadence/tests/evm_state_helpers.cdc | 34 +++- cadence/tests/simulation_base_case_stress.cdc | 105 +++++++--- .../set_uniswap_v3_pool_price.cdc | 192 +++++++++++++++--- 3 files changed, 275 insertions(+), 56 deletions(-) diff --git a/cadence/tests/evm_state_helpers.cdc b/cadence/tests/evm_state_helpers.cdc index dff7a128..c2f4ce32 100644 --- a/cadence/tests/evm_state_helpers.cdc +++ b/cadence/tests/evm_state_helpers.cdc @@ -45,7 +45,39 @@ access(all) fun setPoolToPrice( code: Test.readFile("transactions/set_uniswap_v3_pool_price.cdc"), authorizers: [signer.address], signers: [signer], - arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot] + arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot, 0.0, 0.0, 1.0, 0 as Int256, 0.0] + ) + ) + Test.expect(seedResult, Test.beSucceeded()) +} + +/// Set Uniswap V3 pool to a specific price with finite TVL and concentrated liquidity. +/// tvl: total pool TVL in USD (e.g. 10_000_000.0 for $10M) +/// concentration: fraction 0.0-1.0 — controls tick range width (0.80 = ±~10% range, 0.95 = ±~2.5% range) +/// tokenBPriceUSD: USD price of tokenB (e.g. 1.0 for stablecoins) +/// tickRange: optional explicit ±ticks from current price (0 = derive from concentration, 100 = Python sim default) +/// tvlFraction: optional fraction of TVL to place (0.0 = use all TVL, 0.95 = 95% matching Python concentration) +access(all) fun setPoolToPriceWithTVL( + factoryAddress: String, + tokenAAddress: String, + tokenBAddress: String, + fee: UInt64, + priceTokenBPerTokenA: UFix128, + tokenABalanceSlot: UInt256, + tokenBBalanceSlot: UInt256, + tvl: UFix64, + concentration: UFix64, + tokenBPriceUSD: UFix64, + signer: Test.TestAccount, + tickRange: Int, + tvlFraction: UFix64 +) { + let seedResult = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/set_uniswap_v3_pool_price.cdc"), + authorizers: [signer.address], + signers: [signer], + arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot, tvl, concentration, tokenBPriceUSD, Int256(tickRange), tvlFraction] ) ) Test.expect(seedResult, Test.beSucceeded()) diff --git a/cadence/tests/simulation_base_case_stress.cdc b/cadence/tests/simulation_base_case_stress.cdc index e7e6ff24..6db4156f 100644 --- a/cadence/tests/simulation_base_case_stress.cdc +++ b/cadence/tests/simulation_base_case_stress.cdc @@ -78,6 +78,12 @@ access(all) struct SimConfig { access(all) let maxHealth: UFix64 /// Initial effective HF to coerce the position to at creation (matches Python initial_hf) access(all) let initialHF: UFix64 + /// MOET:YT pool TVL in USD (from fixture's moet_yt.size) + access(all) let moetYtPoolTVL: UFix64 + /// MOET:YT pool concentration — fraction of TVL placed as liquidity (0.95 = 95%) + access(all) let moetYtPoolConcentration: UFix64 + /// MOET:YT pool tick range — ±ticks from peg (100 = Python sim default) + access(all) let moetYtPoolTickRange: Int init( prices: [UFix64], @@ -90,7 +96,10 @@ access(all) struct SimConfig { minHealth: UFix64, targetHealth: UFix64, maxHealth: UFix64, - initialHF: UFix64 + initialHF: UFix64, + moetYtPoolTVL: UFix64, + moetYtPoolConcentration: UFix64, + moetYtPoolTickRange: Int ) { self.prices = prices self.tickIntervalSeconds = tickIntervalSeconds @@ -103,6 +112,9 @@ access(all) struct SimConfig { self.targetHealth = targetHealth self.maxHealth = maxHealth self.initialHF = initialHF + self.moetYtPoolTVL = moetYtPoolTVL + self.moetYtPoolConcentration = moetYtPoolConcentration + self.moetYtPoolTickRange = moetYtPoolTickRange } } @@ -151,8 +163,10 @@ fun setup() { signer: coaOwnerAccount ) - // MOET:morphoVault (yield token pool) - setPoolToPrice( + // MOET:morphoVault (yield token pool) — finite liquidity matching Python sim + // ±100 ticks with 95% of $500K TVL, same as Python _initialize_symmetric_yield_token_positions + let moetYtPool = simulation_ht_vs_aave_pools["moet_yt"]! + setPoolToPriceWithTVL( factoryAddress: factoryAddress, tokenAAddress: moetAddress, tokenBAddress: morphoVaultAddress, @@ -160,7 +174,12 @@ fun setup() { priceTokenBPerTokenA: 1.0, tokenABalanceSlot: moetBalanceSlot, tokenBBalanceSlot: fusdevBalanceSlot, - signer: coaOwnerAccount + tvl: moetYtPool.size, + concentration: moetYtPool.concentration, + tokenBPriceUSD: 1.0, + signer: coaOwnerAccount, + tickRange: 100, + tvlFraction: moetYtPool.concentration ) // MOET:PYUSD0 (routing pool) @@ -225,14 +244,15 @@ access(all) fun ytPriceAtTick(_ tick: Int, tickIntervalSeconds: UFix64, yieldAPR return 1.0 + yieldAPR * (elapsedSeconds / secondsPerYear) } -/// Update all prices for a given simulation tick. +/// Update oracle, collateral pool, and vault share price each tick. +/// Does NOT touch the MOET/FUSDEV pool — that's managed by the arb bot reset. access(all) fun applyPriceTick(btcPrice: UFix64, ytPrice: UFix64, signer: Test.TestAccount) { setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { "BTC": btcPrice, "USD": 1.0 }) - // PYUSD0:WBTC pool — update BTC price + // PYUSD0:WBTC pool — update BTC price (infinite liquidity) setPoolToPrice( factoryAddress: factoryAddress, tokenAAddress: wbtcAddress, @@ -244,18 +264,6 @@ access(all) fun applyPriceTick(btcPrice: UFix64, ytPrice: UFix64, signer: Test.T signer: coaOwnerAccount ) - // MOET:FUSDEV pool — update YT price - setPoolToPrice( - factoryAddress: factoryAddress, - tokenAAddress: moetAddress, - tokenBAddress: morphoVaultAddress, - fee: 100, - priceTokenBPerTokenA: UFix128(ytPrice), - tokenABalanceSlot: moetBalanceSlot, - tokenBBalanceSlot: fusdevBalanceSlot, - signer: coaOwnerAccount - ) - setVaultSharePrice( vaultAddress: morphoVaultAddress, assetAddress: pyusd0Address, @@ -267,6 +275,27 @@ access(all) fun applyPriceTick(btcPrice: UFix64, ytPrice: UFix64, signer: Test.T ) } +/// Arb bot simulation: reset MOET/FUSDEV pool to peg with finite TVL. +/// Called after all agents trade each tick. Matches Python sim arb bot +/// which pushes the pool back toward peg every tick. +access(all) fun resetYieldPoolToFiniteTVL(ytPrice: UFix64, tvl: UFix64, tvlFraction: UFix64, tickRange: Int) { + setPoolToPriceWithTVL( + factoryAddress: factoryAddress, + tokenAAddress: moetAddress, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: UFix128(ytPrice), + tokenABalanceSlot: moetBalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + tvl: tvl, + concentration: tvlFraction, + tokenBPriceUSD: 1.0, + signer: coaOwnerAccount, + tickRange: tickRange, + tvlFraction: tvlFraction + ) +} + // ============================================================================ // SIMULATION RUNNER // ============================================================================ @@ -391,7 +420,7 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult { // Calculate HF BEFORE rebalancing var preRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) - // Rebalance agents on interval + // Rebalance agents sequentially — each swap moves pool price for next agent if config.rebalanceInterval <= 1 || step % config.rebalanceInterval == 0 { var a = 0 while a < config.numAgents { @@ -401,6 +430,9 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult { } } + // Arb bot: reset MOET:FUSDEV pool to peg with finite TVL + resetYieldPoolToFiniteTVL(ytPrice: ytPrice, tvl: config.moetYtPoolTVL, tvlFraction: config.moetYtPoolConcentration, tickRange: config.moetYtPoolTickRange) + // Count actual rebalances that occurred this tick let currentVaultRebalanceCount = Test.eventsOfType(Type()).length let currentPositionRebalanceCount = Test.eventsOfType(Type()).length @@ -496,8 +528,11 @@ fun test_Aggressive_1_01_ZeroLiquidations() { rebalanceInterval: 1, minHealth: 1.01, targetHealth: 1.01000001, - maxHealth: 1.5, - initialHF: 1.15 // midpoint of Python initial_hf_range (1.1, 1.2) + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHF: 1.15, // midpoint of Python initial_hf_range (1.1, 1.2) + moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, + moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Aggressive_1.01" ) @@ -527,8 +562,11 @@ fun test_Balanced_1_1_ZeroLiquidations() { rebalanceInterval: 1, minHealth: 1.1, targetHealth: 1.10000001, - maxHealth: 1.5, - initialHF: 1.35 // midpoint of Python initial_hf_range (1.25, 1.45) + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHF: 1.35, // midpoint of Python initial_hf_range (1.25, 1.45) + moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, + moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Balanced_1.1" ) @@ -558,8 +596,11 @@ fun test_Conservative_1_05_ZeroLiquidations() { rebalanceInterval: 1, minHealth: 1.05, targetHealth: 1.05000001, - maxHealth: 1.5, - initialHF: 1.4 // midpoint of Python initial_hf_range (1.3, 1.5) + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHF: 1.4, // midpoint of Python initial_hf_range (1.3, 1.5) + moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, + moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Conservative_1.05" ) @@ -589,8 +630,11 @@ fun test_Mixed_1_075_ZeroLiquidations() { rebalanceInterval: 1, minHealth: 1.075, targetHealth: 1.07500001, - maxHealth: 1.5, - initialHF: 1.3 // midpoint of Python initial_hf_range (1.1, 1.5) + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHF: 1.3, // midpoint of Python initial_hf_range (1.1, 1.5) + moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, + moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Mixed_1.075" ) @@ -620,8 +664,11 @@ fun test_Moderate_1_025_ZeroLiquidations() { rebalanceInterval: 1, minHealth: 1.025, targetHealth: 1.02500001, - maxHealth: 1.5, - initialHF: 1.3 // midpoint of Python initial_hf_range (1.2, 1.4) + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHF: 1.3, // midpoint of Python initial_hf_range (1.2, 1.4) + moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, + moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Moderate_1.025" ) diff --git a/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc index 819c31fe..78c5dbcd 100644 --- a/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc +++ b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc @@ -4,7 +4,7 @@ import EVM from "MockEVM" access(all) fun computeMappingSlot(_ values: [AnyStruct]): String { let encoded = EVM.encodeABI(values) let hashBytes = HashAlgorithm.KECCAK_256.hash(encoded) - return String.encodeHex(hashBytes) + return "0x\(String.encodeHex(hashBytes))" } // Helper: Compute ERC20 balanceOf storage slot @@ -33,12 +33,16 @@ access(all) fun toHex32(_ value: UInt256): String { // Helper: Convert a slot number (UInt256) to its padded hex string for EVM.store/load access(all) fun slotHex(_ slotNum: UInt256): String { - return toHex32(slotNum) + return "0x\(toHex32(slotNum))" } // Helper: Parse a hex slot string back to UInt256 -access(all) fun slotToNum(_ slotHex: String): UInt256 { - let bytes = slotHex.decodeHex() +access(all) fun slotToNum(_ slot: String): UInt256 { + var hex = slot + if hex.length > 2 && hex.slice(from: 0, upTo: 2) == "0x" { + hex = hex.slice(from: 2, upTo: hex.length) + } + let bytes = hex.decodeHex() var num = 0 as UInt256 for byte in bytes { num = num * 256 + UInt256(byte) @@ -48,6 +52,9 @@ access(all) fun slotToNum(_ slotHex: String): UInt256 { // Properly seed Uniswap V3 pool with STRUCTURALLY VALID state // This creates: slot0, observations, liquidity, ticks (with initialized flag), bitmap, and token balances +// Pass 0.0 for tvl and concentration to create a full-range infinite liquidity pool (useful for no slippage) +// When tickRange > 0: use explicit ±tickRange ticks and tvlFraction of TVL (matches Python sim) +// When tickRange == 0: fall through to concentration-based or infinite liquidity mode transaction( factoryAddress: String, tokenAAddress: String, @@ -55,7 +62,12 @@ transaction( fee: UInt64, priceTokenBPerTokenA: UFix128, tokenABalanceSlot: UInt256, - tokenBBalanceSlot: UInt256 + tokenBBalanceSlot: UInt256, + tvl: UFix64, + concentration: UFix64, + tokenBPriceUSD: UFix64, + tickRange: Int256, + tvlFraction: UFix64 ) { let coa: auth(EVM.Call) &EVM.CadenceOwnedAccount prepare(signer: auth(Storage) &Account) { @@ -171,9 +183,142 @@ transaction( let tickSpacing = (EVM.decodeABI(types: [Type()], data: spacingResult.data)[0] as! Int256) - // Use FULL RANGE ticks (min/max for Uniswap V3), aligned to tickSpacing - let tickLower = (-887272 as Int256) / tickSpacing * tickSpacing - let tickUpper = (887272 as Int256) / tickSpacing * tickSpacing + // Compute tick range, liquidity, and token balances based on TVL mode + let Q96: UInt256 = UInt256(1) << 96 + var tickLower: Int256 = 0 + var tickUpper: Int256 = 0 + var liquidityAmount: UInt256 = 0 + var token0Balance: UInt256 = 0 + var token1Balance: UInt256 = 0 + + if tickRange > 0 && tvl > 0.0 { + // --- Explicit tick range mode (matches Python sim) --- + // Use ±tickRange ticks from current price, place tvlFraction of TVL + tickLower = 0 - tickRange + tickUpper = tickRange + + // Align to tick spacing + tickLower = tickLower / tickSpacing * tickSpacing + tickUpper = tickUpper / tickSpacing * tickSpacing + if tickUpper == 0 { tickUpper = tickSpacing } + + assert(tickLower < tickUpper, message: "Tick range is empty after alignment") + + let sqrtPa = getSqrtRatioAtTick(tick: tickLower) + let sqrtPb = getSqrtRatioAtTick(tick: tickUpper) + + // Convert (TVL/2 * tvlFraction) from USD to token1 smallest units + let effectiveBPrice = tokenBPriceUSD > 0.0 ? tokenBPriceUSD : 1.0 + var token1PriceUSD = effectiveBPrice + if tokenAAddress >= tokenBAddress { + token1PriceUSD = UFix64(priceTokenBPerTokenA) * effectiveBPrice + } + let effectiveFraction = tvlFraction > 0.0 ? tvlFraction : 1.0 + let tvlHalfToken1 = tvl / 2.0 * effectiveFraction / token1PriceUSD + let tvlHalfWhole = UInt256(UInt64(tvlHalfToken1)) + var tvlHalfSmallest = tvlHalfWhole + var tdr: UInt8 = 0 + while tdr < token1Decimals { + tvlHalfSmallest = tvlHalfSmallest * 10 + tdr = tdr + 1 + } + + // L = tvlHalfSmallest * Q96 / (sqrtP - sqrtPa) + let sqrtPDiffA = targetSqrtPriceX96 - sqrtPa + assert(sqrtPDiffA > 0, message: "sqrtP must be > sqrtPa for liquidity calculation") + liquidityAmount = tvlHalfSmallest * Q96 / sqrtPDiffA + + // token1 = L * (sqrtP - sqrtPa) / Q96 + token1Balance = liquidityAmount * sqrtPDiffA / Q96 + + // token0 = L * (sqrtPb - sqrtP) / sqrtPb * Q96 / sqrtP + let sqrtPDiffB = sqrtPb - targetSqrtPriceX96 + token0Balance = liquidityAmount * sqrtPDiffB / sqrtPb * Q96 / targetSqrtPriceX96 + + } else if tvl > 0.0 && concentration > 0.0 && concentration < 1.0 { + // --- Concentrated liquidity mode --- + let halfWidth = 1.0 - concentration + + // sqrt(1 +/- halfWidth) via integer sqrt at 1e16 scale for 8-digit precision + let PRECISION: UInt256 = 10_000_000_000_000_000 + let SQRT_PRECISION: UInt256 = 100_000_000 + let halfWidthScaled = UInt256(UInt64(halfWidth * 100_000_000.0)) * 100_000_000 + + let upperMultNum = isqrt(PRECISION + halfWidthScaled) + let lowerMultNum = isqrt(PRECISION - halfWidthScaled) + + var sqrtPriceUpper = targetSqrtPriceX96 * upperMultNum / SQRT_PRECISION + var sqrtPriceLower = targetSqrtPriceX96 * lowerMultNum / SQRT_PRECISION + + let MAX_SQRT: UInt256 = 1461446703485210103287273052203988822378723970341 + let MIN_SQRT: UInt256 = 4295128739 + if sqrtPriceUpper > MAX_SQRT { sqrtPriceUpper = MAX_SQRT } + if sqrtPriceLower < MIN_SQRT + 1 { sqrtPriceLower = MIN_SQRT + 1 } + + let rawTickUpper = getTickAtSqrtRatio(sqrtPriceX96: sqrtPriceUpper) + let rawTickLower = getTickAtSqrtRatio(sqrtPriceX96: sqrtPriceLower) + + // Align tickLower down, tickUpper up to tickSpacing + tickLower = rawTickLower / tickSpacing * tickSpacing + if rawTickLower < 0 && rawTickLower % tickSpacing != 0 { + tickLower = tickLower - tickSpacing + } + tickUpper = rawTickUpper / tickSpacing * tickSpacing + if rawTickUpper > 0 && rawTickUpper % tickSpacing != 0 { + tickUpper = tickUpper + tickSpacing + } + + assert(tickLower < tickUpper, message: "Concentrated tick range is empty after alignment") + + let sqrtPa = getSqrtRatioAtTick(tick: tickLower) + let sqrtPb = getSqrtRatioAtTick(tick: tickUpper) + + // Convert TVL/2 from USD to token1 smallest units using token prices + let effectiveBPrice = tokenBPriceUSD > 0.0 ? tokenBPriceUSD : 1.0 + var token1PriceUSD = effectiveBPrice + if tokenAAddress >= tokenBAddress { + // token1 = tokenA; tokenA is worth priceTokenBPerTokenA * tokenBPrice in USD + token1PriceUSD = UFix64(priceTokenBPerTokenA) * effectiveBPrice + } + let tvlHalfToken1 = tvl / 2.0 / token1PriceUSD + let tvlHalfWhole = UInt256(UInt64(tvlHalfToken1)) + var tvlHalfSmallest = tvlHalfWhole + var td: UInt8 = 0 + while td < token1Decimals { + tvlHalfSmallest = tvlHalfSmallest * 10 + td = td + 1 + } + + // L = tvlHalfSmallest * Q96 / (sqrtP - sqrtPa) + let sqrtPDiffA = targetSqrtPriceX96 - sqrtPa + assert(sqrtPDiffA > 0, message: "sqrtP must be > sqrtPa for liquidity calculation") + liquidityAmount = tvlHalfSmallest * Q96 / sqrtPDiffA + + // token1 = L * (sqrtP - sqrtPa) / Q96 + token1Balance = liquidityAmount * sqrtPDiffA / Q96 + + // token0 = L * (sqrtPb - sqrtP) / sqrtPb * Q96 / sqrtP + let sqrtPDiffB = sqrtPb - targetSqrtPriceX96 + token0Balance = liquidityAmount * sqrtPDiffB / sqrtPb * Q96 / targetSqrtPriceX96 + } else { + // --- Full-range infinite liquidity mode (backward compatible) --- + tickLower = (-887272 as Int256) / tickSpacing * tickSpacing + tickUpper = (887272 as Int256) / tickSpacing * tickSpacing + liquidityAmount = 340282366920938463463374607431768211455 // 2^128 - 1 + + token0Balance = 1000000000 + var ti: UInt8 = 0 + while ti < token0Decimals { + token0Balance = token0Balance * 10 + ti = ti + 1 + } + token1Balance = 1000000000 + ti = 0 + while ti < token1Decimals { + token1Balance = token1Balance * 10 + ti = ti + 1 + } + } // Pack slot0 for Solidity storage layout // Struct fields packed right-to-left (LSB to MSB): @@ -214,8 +359,7 @@ transaction( EVM.store(target: poolAddr, slot: slotHex(2), value: zero32) EVM.store(target: poolAddr, slot: slotHex(3), value: zero32) - // --- Slot 4: liquidity = uint128 max --- - let liquidityAmount: UInt256 = 340282366920938463463374607431768211455 // 2^128 - 1 + // --- Slot 4: liquidity --- EVM.store(target: poolAddr, slot: slotHex(4), value: toHex32(liquidityAmount)) // --- Initialize boundary ticks --- @@ -296,22 +440,6 @@ transaction( EVM.store(target: poolAddr, slot: slotHex(8), value: String.encodeHex(obs0Bytes)) // --- Fund pool with token balances --- - // Calculate 1 billion tokens in each token's decimal format - var token0Balance: UInt256 = 1000000000 - var i: UInt8 = 0 - while i < token0Decimals { - token0Balance = token0Balance * 10 - i = i + 1 - } - - var token1Balance: UInt256 = 1000000000 - i = 0 - while i < token1Decimals { - token1Balance = token1Balance * 10 - i = i + 1 - } - - // Set token balances (padded to 32 bytes) let token0BalanceSlotComputed = computeBalanceOfSlot(holderAddress: poolAddress, balanceSlot: token0BalanceSlot) EVM.store(target: token0, slot: token0BalanceSlotComputed, value: toHex32(token0Balance)) @@ -670,6 +798,18 @@ access(all) fun bytesToUInt256(_ bytes: [UInt8]): UInt256 { return result } +/// Integer square root via Newton's method. Returns floor(sqrt(x)). +access(all) fun isqrt(_ x: UInt256): UInt256 { + if x == 0 { return 0 } + var z = x + var y = (z + 1) / 2 + while y < z { + z = y + y = (z + x / z) / 2 + } + return z +} + access(all) fun getTokenDecimals(evmContractAddress: EVM.EVMAddress): UInt8 { let zeroAddress = EVM.addressFromString("0x0000000000000000000000000000000000000000") let callResult = EVM.dryCall( From 3b204619f3827c2e3ea53b39369ce3a24444243a Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 1 Apr 2026 16:51:59 -0700 Subject: [PATCH 7/9] Distribute starting HF values to add entropy to multi-agent configuration --- cadence/tests/simulation_base_case_stress.cdc | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/cadence/tests/simulation_base_case_stress.cdc b/cadence/tests/simulation_base_case_stress.cdc index 6db4156f..817d9944 100644 --- a/cadence/tests/simulation_base_case_stress.cdc +++ b/cadence/tests/simulation_base_case_stress.cdc @@ -76,8 +76,10 @@ access(all) struct SimConfig { access(all) let minHealth: UFix64 access(all) let targetHealth: UFix64 access(all) let maxHealth: UFix64 - /// Initial effective HF to coerce the position to at creation (matches Python initial_hf) - access(all) let initialHF: UFix64 + /// Initial HF range — agents are linearly spread across [low, high] + /// (Python sim uses random.uniform; linear spread is the deterministic equivalent) + access(all) let initialHFLow: UFix64 + access(all) let initialHFHigh: UFix64 /// MOET:YT pool TVL in USD (from fixture's moet_yt.size) access(all) let moetYtPoolTVL: UFix64 /// MOET:YT pool concentration — fraction of TVL placed as liquidity (0.95 = 95%) @@ -96,7 +98,8 @@ access(all) struct SimConfig { minHealth: UFix64, targetHealth: UFix64, maxHealth: UFix64, - initialHF: UFix64, + initialHFLow: UFix64, + initialHFHigh: UFix64, moetYtPoolTVL: UFix64, moetYtPoolConcentration: UFix64, moetYtPoolTickRange: Int @@ -111,7 +114,8 @@ access(all) struct SimConfig { self.minHealth = minHealth self.targetHealth = targetHealth self.maxHealth = maxHealth - self.initialHF = initialHF + self.initialHFLow = initialHFLow + self.initialHFHigh = initialHFHigh self.moetYtPoolTVL = moetYtPoolTVL self.moetYtPoolConcentration = moetYtPoolConcentration self.moetYtPoolTickRange = moetYtPoolTickRange @@ -344,15 +348,20 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult { let yieldVaultIDs = getYieldVaultIDs(address: user.address)! let vaultId = yieldVaultIDs[0] + // Linearly spread initial HF across [low, high] (Python uses random.uniform) + let agentInitialHF = config.numAgents > 1 + ? config.initialHFLow + (config.initialHFHigh - config.initialHFLow) * UFix64(i) / UFix64(config.numAgents - 1) + : config.initialHFLow + // Step 1: Coerce position to the desired initial HF. // Set temporary health params with targetHealth=initialHF, then force-rebalance. // This makes the on-chain rebalancer push the position to exactly initialHF. setPositionHealth( signer: flowALPAccount, pid: pid, - minHealth: config.initialHF - 0.01, - targetHealth: config.initialHF, - maxHealth: config.initialHF + 0.01 + minHealth: agentInitialHF - 0.01, + targetHealth: agentInitialHF, + maxHealth: agentInitialHF + 0.01 ) rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) @@ -369,7 +378,7 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult { pids.append(pid) vaultIds.append(vaultId) - log(" Agent \(i): pid=\(pid) vaultId=\(vaultId)") + log(" Agent \(i): pid=\(pid) vaultId=\(vaultId) initialHF=\(agentInitialHF)") i = i + 1 } @@ -379,7 +388,7 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult { log("Tick interval: \(config.tickIntervalSeconds)s") log("Price points: \(prices.length)") log("Initial BTC price: $\(prices[0])") - log("Initial HF: \(config.initialHF)") + log("Initial HF range: \(config.initialHFLow) - \(config.initialHFHigh)") log("") log("Rebalance Triggers:") log(" HF (Position): triggers when HF < \(config.minHealth), rebalances to HF = \(config.targetHealth)") @@ -492,7 +501,7 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult { log("Final BTC price: $\(finalPrice)") log("") log("--- Position ---") - log("Initial HF: \(config.initialHF)") + log("Initial HF range: \(config.initialHFLow) - \(config.initialHFHigh)") log("Lowest HF observed: \(lowestHF)") log("Final HF (agent 0): \(finalHF)") log("Final collateral: \(finalBTCCollateral) BTC (value: \(collateralValueMOET) MOET)") @@ -529,7 +538,8 @@ fun test_Aggressive_1_01_ZeroLiquidations() { minHealth: 1.01, targetHealth: 1.01000001, maxHealth: UFix64.max, // Python sim has no upper health bound - initialHF: 1.15, // midpoint of Python initial_hf_range (1.1, 1.2) + initialHFLow: 1.1, // Python initial_hf_range + initialHFHigh: 1.2, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) @@ -563,7 +573,8 @@ fun test_Balanced_1_1_ZeroLiquidations() { minHealth: 1.1, targetHealth: 1.10000001, maxHealth: UFix64.max, // Python sim has no upper health bound - initialHF: 1.35, // midpoint of Python initial_hf_range (1.25, 1.45) + initialHFLow: 1.25, // Python initial_hf_range + initialHFHigh: 1.45, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) @@ -597,7 +608,8 @@ fun test_Conservative_1_05_ZeroLiquidations() { minHealth: 1.05, targetHealth: 1.05000001, maxHealth: UFix64.max, // Python sim has no upper health bound - initialHF: 1.4, // midpoint of Python initial_hf_range (1.3, 1.5) + initialHFLow: 1.3, // Python initial_hf_range + initialHFHigh: 1.5, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) @@ -631,7 +643,8 @@ fun test_Mixed_1_075_ZeroLiquidations() { minHealth: 1.075, targetHealth: 1.07500001, maxHealth: UFix64.max, // Python sim has no upper health bound - initialHF: 1.3, // midpoint of Python initial_hf_range (1.1, 1.5) + initialHFLow: 1.1, // Python initial_hf_range + initialHFHigh: 1.5, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) @@ -665,7 +678,8 @@ fun test_Moderate_1_025_ZeroLiquidations() { minHealth: 1.025, targetHealth: 1.02500001, maxHealth: UFix64.max, // Python sim has no upper health bound - initialHF: 1.3, // midpoint of Python initial_hf_range (1.2, 1.4) + initialHFLow: 1.2, // Python initial_hf_range + initialHFHigh: 1.4, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) From 7dd931e8f5f45fa4d4aabf523f5e6f68f0ea5905 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 1 Apr 2026 17:11:06 -0700 Subject: [PATCH 8/9] Address feedback --- cadence/tests/simulation_base_case_stress.cdc | 85 ++++++++++--------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/cadence/tests/simulation_base_case_stress.cdc b/cadence/tests/simulation_base_case_stress.cdc index 817d9944..fb668922 100644 --- a/cadence/tests/simulation_base_case_stress.cdc +++ b/cadence/tests/simulation_base_case_stress.cdc @@ -417,59 +417,62 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult { highestPrice = absolutePrice } - if absolutePrice != previousBTCPrice { - let expectedTimestamp = startTimestamp + UFix64(step) * config.tickIntervalSeconds - let currentTimestamp = getCurrentBlockTimestamp() - if expectedTimestamp > currentTimestamp { - Test.moveTime(by: Fix64(expectedTimestamp - currentTimestamp)) - } + if absolutePrice == previousBTCPrice { + step = step + 1 + continue + } - applyPriceTick(btcPrice: absolutePrice, ytPrice: ytPrice, signer: users[0]) + let expectedTimestamp = startTimestamp + UFix64(step) * config.tickIntervalSeconds + let currentTimestamp = getCurrentBlockTimestamp() + if expectedTimestamp > currentTimestamp { + Test.moveTime(by: Fix64(expectedTimestamp - currentTimestamp)) + } - // Calculate HF BEFORE rebalancing - var preRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) + applyPriceTick(btcPrice: absolutePrice, ytPrice: ytPrice, signer: users[0]) - // Rebalance agents sequentially — each swap moves pool price for next agent - if config.rebalanceInterval <= 1 || step % config.rebalanceInterval == 0 { - var a = 0 - while a < config.numAgents { - rebalanceYieldVault(signer: flowYieldVaultsAccount, id: vaultIds[a], force: false, beFailed: false) - rebalancePosition(signer: flowALPAccount, pid: pids[a], force: false, beFailed: false) - a = a + 1 - } - } + // Calculate HF BEFORE rebalancing + var preRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) - // Arb bot: reset MOET:FUSDEV pool to peg with finite TVL - resetYieldPoolToFiniteTVL(ytPrice: ytPrice, tvl: config.moetYtPoolTVL, tvlFraction: config.moetYtPoolConcentration, tickRange: config.moetYtPoolTickRange) + // Rebalance agents sequentially — each swap moves pool price for next agent + if config.rebalanceInterval <= 1 || step % config.rebalanceInterval == 0 { + var a = 0 + while a < config.numAgents { + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: vaultIds[a], force: false, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pids[a], force: false, beFailed: false) + a = a + 1 + } + } - // Count actual rebalances that occurred this tick - let currentVaultRebalanceCount = Test.eventsOfType(Type()).length - let currentPositionRebalanceCount = Test.eventsOfType(Type()).length - let tickVaultRebalances = currentVaultRebalanceCount - prevVaultRebalanceCount - let tickPositionRebalances = currentPositionRebalanceCount - prevPositionRebalanceCount - prevVaultRebalanceCount = currentVaultRebalanceCount - prevPositionRebalanceCount = currentPositionRebalanceCount + // Arb bot: reset MOET:FUSDEV pool to peg with finite TVL + resetYieldPoolToFiniteTVL(ytPrice: ytPrice, tvl: config.moetYtPoolTVL, tvlFraction: config.moetYtPoolConcentration, tickRange: config.moetYtPoolTickRange) - // Calculate HF AFTER rebalancing - var postRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) + // Count actual rebalances that occurred this tick + let currentVaultRebalanceCount = Test.eventsOfType(Type()).length + let currentPositionRebalanceCount = Test.eventsOfType(Type()).length + let tickVaultRebalances = currentVaultRebalanceCount - prevVaultRebalanceCount + let tickPositionRebalances = currentPositionRebalanceCount - prevPositionRebalanceCount + prevVaultRebalanceCount = currentVaultRebalanceCount + prevPositionRebalanceCount = currentPositionRebalanceCount - // Track lowest HF (use pre-rebalance to capture the actual low point) - if preRebalanceHF < lowestHF && preRebalanceHF > 0.0 { - lowestHF = preRebalanceHF - } + // Calculate HF AFTER rebalancing + var postRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) - // Log every tick with pre→post HF - log(" [t=\(step)] price=$\(absolutePrice) yt=\(ytPrice) HF=\(preRebalanceHF)->\(postRebalanceHF) vaultRebalances=\(tickVaultRebalances) positionRebalances=\(tickPositionRebalances)") + // Track lowest HF (use pre-rebalance to capture the actual low point) + if preRebalanceHF < lowestHF && preRebalanceHF > 0.0 { + lowestHF = preRebalanceHF + } - // Liquidation check (pre-rebalance HF is the danger point) - if preRebalanceHF < 1.0 && preRebalanceHF > 0.0 { - liquidationCount = liquidationCount + 1 - log(" *** LIQUIDATION agent=0 at t=\(step)! HF=\(preRebalanceHF) ***") - } + // Log every tick with pre→post HF + log(" [t=\(step)] price=$\(absolutePrice) yt=\(ytPrice) HF=\(preRebalanceHF)->\(postRebalanceHF) vaultRebalances=\(tickVaultRebalances) positionRebalances=\(tickPositionRebalances)") - previousBTCPrice = absolutePrice + // Liquidation check (pre-rebalance HF is the danger point) + if preRebalanceHF < 1.0 && preRebalanceHF > 0.0 { + liquidationCount = liquidationCount + 1 + log(" *** LIQUIDATION agent=0 at t=\(step)! HF=\(preRebalanceHF) ***") } + previousBTCPrice = absolutePrice + step = step + 1 } From b67ffa2def94c2e653480609260ee94edc04425a Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 1 Apr 2026 23:37:04 -0700 Subject: [PATCH 9/9] Cleanup pool concentration helpers --- cadence/tests/evm_state_helpers.cdc | 24 ++-- cadence/tests/simulation_base_case_stress.cdc | 34 +++-- .../set_uniswap_v3_pool_price.cdc | 126 ++++-------------- 3 files changed, 50 insertions(+), 134 deletions(-) diff --git a/cadence/tests/evm_state_helpers.cdc b/cadence/tests/evm_state_helpers.cdc index c2f4ce32..5a32e108 100644 --- a/cadence/tests/evm_state_helpers.cdc +++ b/cadence/tests/evm_state_helpers.cdc @@ -27,9 +27,7 @@ access(all) fun setVaultSharePrice( /* --- Uniswap V3 Pool State Manipulation --- */ -/// Set Uniswap V3 pool to a specific price via EVM.store -/// Creates pool if it doesn't exist, then manipulates state -/// Price is specified as UFix128 for high precision (24 decimal places) +/// Set Uniswap V3 pool to a specific price with infinite liquidity (zero slippage). access(all) fun setPoolToPrice( factoryAddress: String, tokenAAddress: String, @@ -45,18 +43,17 @@ access(all) fun setPoolToPrice( code: Test.readFile("transactions/set_uniswap_v3_pool_price.cdc"), authorizers: [signer.address], signers: [signer], - arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot, 0.0, 0.0, 1.0, 0 as Int256, 0.0] + arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot, 0.0, 0 as Int256, 0.0, 1.0] ) ) Test.expect(seedResult, Test.beSucceeded()) } /// Set Uniswap V3 pool to a specific price with finite TVL and concentrated liquidity. -/// tvl: total pool TVL in USD (e.g. 10_000_000.0 for $10M) -/// concentration: fraction 0.0-1.0 — controls tick range width (0.80 = ±~10% range, 0.95 = ±~2.5% range) -/// tokenBPriceUSD: USD price of tokenB (e.g. 1.0 for stablecoins) -/// tickRange: optional explicit ±ticks from current price (0 = derive from concentration, 100 = Python sim default) -/// tvlFraction: optional fraction of TVL to place (0.0 = use all TVL, 0.95 = 95% matching Python concentration) +/// tvl: total pool TVL in USD (e.g. 500_000.0) +/// tickRange: ±ticks from current price (e.g. 100 = ±1% for tick_spacing=10) +/// tvlFraction: fraction of TVL placed as liquidity (e.g. 0.95 = 95%) +/// tokenBPriceUSD: USD price of tokenB (1.0 for stablecoins) access(all) fun setPoolToPriceWithTVL( factoryAddress: String, tokenAAddress: String, @@ -65,19 +62,18 @@ access(all) fun setPoolToPriceWithTVL( priceTokenBPerTokenA: UFix128, tokenABalanceSlot: UInt256, tokenBBalanceSlot: UInt256, - tvl: UFix64, - concentration: UFix64, - tokenBPriceUSD: UFix64, signer: Test.TestAccount, + tvl: UFix64, tickRange: Int, - tvlFraction: UFix64 + tvlFraction: UFix64, + tokenBPriceUSD: UFix64 ) { let seedResult = Test.executeTransaction( Test.Transaction( code: Test.readFile("transactions/set_uniswap_v3_pool_price.cdc"), authorizers: [signer.address], signers: [signer], - arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot, tvl, concentration, tokenBPriceUSD, Int256(tickRange), tvlFraction] + arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot, tvl, Int256(tickRange), tvlFraction, tokenBPriceUSD] ) ) Test.expect(seedResult, Test.beSucceeded()) diff --git a/cadence/tests/simulation_base_case_stress.cdc b/cadence/tests/simulation_base_case_stress.cdc index fb668922..600dbcae 100644 --- a/cadence/tests/simulation_base_case_stress.cdc +++ b/cadence/tests/simulation_base_case_stress.cdc @@ -82,8 +82,8 @@ access(all) struct SimConfig { access(all) let initialHFHigh: UFix64 /// MOET:YT pool TVL in USD (from fixture's moet_yt.size) access(all) let moetYtPoolTVL: UFix64 - /// MOET:YT pool concentration — fraction of TVL placed as liquidity (0.95 = 95%) - access(all) let moetYtPoolConcentration: UFix64 + /// MOET:YT pool TVL fraction — fraction of TVL placed as liquidity (0.95 = 95%) + access(all) let moetYtPoolTVLFraction: UFix64 /// MOET:YT pool tick range — ±ticks from peg (100 = Python sim default) access(all) let moetYtPoolTickRange: Int @@ -101,7 +101,7 @@ access(all) struct SimConfig { initialHFLow: UFix64, initialHFHigh: UFix64, moetYtPoolTVL: UFix64, - moetYtPoolConcentration: UFix64, + moetYtPoolTVLFraction: UFix64, moetYtPoolTickRange: Int ) { self.prices = prices @@ -117,7 +117,7 @@ access(all) struct SimConfig { self.initialHFLow = initialHFLow self.initialHFHigh = initialHFHigh self.moetYtPoolTVL = moetYtPoolTVL - self.moetYtPoolConcentration = moetYtPoolConcentration + self.moetYtPoolTVLFraction = moetYtPoolTVLFraction self.moetYtPoolTickRange = moetYtPoolTickRange } } @@ -178,12 +178,11 @@ fun setup() { priceTokenBPerTokenA: 1.0, tokenABalanceSlot: moetBalanceSlot, tokenBBalanceSlot: fusdevBalanceSlot, - tvl: moetYtPool.size, - concentration: moetYtPool.concentration, - tokenBPriceUSD: 1.0, signer: coaOwnerAccount, + tvl: moetYtPool.size, tickRange: 100, - tvlFraction: moetYtPool.concentration + tvlFraction: moetYtPool.concentration, + tokenBPriceUSD: 1.0 ) // MOET:PYUSD0 (routing pool) @@ -291,12 +290,11 @@ access(all) fun resetYieldPoolToFiniteTVL(ytPrice: UFix64, tvl: UFix64, tvlFract priceTokenBPerTokenA: UFix128(ytPrice), tokenABalanceSlot: moetBalanceSlot, tokenBBalanceSlot: fusdevBalanceSlot, - tvl: tvl, - concentration: tvlFraction, - tokenBPriceUSD: 1.0, signer: coaOwnerAccount, + tvl: tvl, tickRange: tickRange, - tvlFraction: tvlFraction + tvlFraction: tvlFraction, + tokenBPriceUSD: 1.0 ) } @@ -444,7 +442,7 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult { } // Arb bot: reset MOET:FUSDEV pool to peg with finite TVL - resetYieldPoolToFiniteTVL(ytPrice: ytPrice, tvl: config.moetYtPoolTVL, tvlFraction: config.moetYtPoolConcentration, tickRange: config.moetYtPoolTickRange) + resetYieldPoolToFiniteTVL(ytPrice: ytPrice, tvl: config.moetYtPoolTVL, tvlFraction: config.moetYtPoolTVLFraction, tickRange: config.moetYtPoolTickRange) // Count actual rebalances that occurred this tick let currentVaultRebalanceCount = Test.eventsOfType(Type()).length @@ -544,7 +542,7 @@ fun test_Aggressive_1_01_ZeroLiquidations() { initialHFLow: 1.1, // Python initial_hf_range initialHFHigh: 1.2, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, - moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTVLFraction: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Aggressive_1.01" @@ -579,7 +577,7 @@ fun test_Balanced_1_1_ZeroLiquidations() { initialHFLow: 1.25, // Python initial_hf_range initialHFHigh: 1.45, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, - moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTVLFraction: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Balanced_1.1" @@ -614,7 +612,7 @@ fun test_Conservative_1_05_ZeroLiquidations() { initialHFLow: 1.3, // Python initial_hf_range initialHFHigh: 1.5, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, - moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTVLFraction: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Conservative_1.05" @@ -649,7 +647,7 @@ fun test_Mixed_1_075_ZeroLiquidations() { initialHFLow: 1.1, // Python initial_hf_range initialHFHigh: 1.5, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, - moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTVLFraction: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Mixed_1.075" @@ -684,7 +682,7 @@ fun test_Moderate_1_025_ZeroLiquidations() { initialHFLow: 1.2, // Python initial_hf_range initialHFHigh: 1.4, moetYtPoolTVL: simulation_ht_vs_aave_pools["moet_yt"]!.size, - moetYtPoolConcentration: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, + moetYtPoolTVLFraction: simulation_ht_vs_aave_pools["moet_yt"]!.concentration, moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range) ), label: "Moderate_1.025" diff --git a/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc index 78c5dbcd..391f5ca6 100644 --- a/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc +++ b/cadence/tests/transactions/set_uniswap_v3_pool_price.cdc @@ -50,11 +50,12 @@ access(all) fun slotToNum(_ slot: String): UInt256 { return num } -// Properly seed Uniswap V3 pool with STRUCTURALLY VALID state -// This creates: slot0, observations, liquidity, ticks (with initialized flag), bitmap, and token balances -// Pass 0.0 for tvl and concentration to create a full-range infinite liquidity pool (useful for no slippage) -// When tickRange > 0: use explicit ±tickRange ticks and tvlFraction of TVL (matches Python sim) -// When tickRange == 0: fall through to concentration-based or infinite liquidity mode +// Properly seed Uniswap V3 pool with STRUCTURALLY VALID state. +// This creates: slot0, observations, liquidity, ticks (with initialized flag), bitmap, and token balances. +// +// Two modes: +// Finite: tvl > 0 && tickRange > 0 — ±tickRange ticks from current price, tvlFraction of TVL placed +// Infinite: otherwise — full-range liquidity with 2^128-1 L (zero slippage) transaction( factoryAddress: String, tokenAAddress: String, @@ -64,10 +65,9 @@ transaction( tokenABalanceSlot: UInt256, tokenBBalanceSlot: UInt256, tvl: UFix64, - concentration: UFix64, - tokenBPriceUSD: UFix64, tickRange: Int256, - tvlFraction: UFix64 + tvlFraction: UFix64, + tokenBPriceUSD: UFix64 ) { let coa: auth(EVM.Call) &EVM.CadenceOwnedAccount prepare(signer: auth(Storage) &Account) { @@ -191,113 +191,47 @@ transaction( var token0Balance: UInt256 = 0 var token1Balance: UInt256 = 0 - if tickRange > 0 && tvl > 0.0 { - // --- Explicit tick range mode (matches Python sim) --- - // Use ±tickRange ticks from current price, place tvlFraction of TVL - tickLower = 0 - tickRange - tickUpper = tickRange + if tvl > 0.0 && tickRange > 0 { + // --- Finite liquidity mode --- + // ±tickRange ticks from current price, tvlFraction of TVL placed + let currentTick = getTickAtSqrtRatio(sqrtPriceX96: targetSqrtPriceX96) + let rawLower = currentTick - tickRange + let rawUpper = currentTick + tickRange - // Align to tick spacing - tickLower = tickLower / tickSpacing * tickSpacing - tickUpper = tickUpper / tickSpacing * tickSpacing - if tickUpper == 0 { tickUpper = tickSpacing } - - assert(tickLower < tickUpper, message: "Tick range is empty after alignment") - - let sqrtPa = getSqrtRatioAtTick(tick: tickLower) - let sqrtPb = getSqrtRatioAtTick(tick: tickUpper) - - // Convert (TVL/2 * tvlFraction) from USD to token1 smallest units - let effectiveBPrice = tokenBPriceUSD > 0.0 ? tokenBPriceUSD : 1.0 - var token1PriceUSD = effectiveBPrice - if tokenAAddress >= tokenBAddress { - token1PriceUSD = UFix64(priceTokenBPerTokenA) * effectiveBPrice - } - let effectiveFraction = tvlFraction > 0.0 ? tvlFraction : 1.0 - let tvlHalfToken1 = tvl / 2.0 * effectiveFraction / token1PriceUSD - let tvlHalfWhole = UInt256(UInt64(tvlHalfToken1)) - var tvlHalfSmallest = tvlHalfWhole - var tdr: UInt8 = 0 - while tdr < token1Decimals { - tvlHalfSmallest = tvlHalfSmallest * 10 - tdr = tdr + 1 - } - - // L = tvlHalfSmallest * Q96 / (sqrtP - sqrtPa) - let sqrtPDiffA = targetSqrtPriceX96 - sqrtPa - assert(sqrtPDiffA > 0, message: "sqrtP must be > sqrtPa for liquidity calculation") - liquidityAmount = tvlHalfSmallest * Q96 / sqrtPDiffA - - // token1 = L * (sqrtP - sqrtPa) / Q96 - token1Balance = liquidityAmount * sqrtPDiffA / Q96 - - // token0 = L * (sqrtPb - sqrtP) / sqrtPb * Q96 / sqrtP - let sqrtPDiffB = sqrtPb - targetSqrtPriceX96 - token0Balance = liquidityAmount * sqrtPDiffB / sqrtPb * Q96 / targetSqrtPriceX96 - - } else if tvl > 0.0 && concentration > 0.0 && concentration < 1.0 { - // --- Concentrated liquidity mode --- - let halfWidth = 1.0 - concentration - - // sqrt(1 +/- halfWidth) via integer sqrt at 1e16 scale for 8-digit precision - let PRECISION: UInt256 = 10_000_000_000_000_000 - let SQRT_PRECISION: UInt256 = 100_000_000 - let halfWidthScaled = UInt256(UInt64(halfWidth * 100_000_000.0)) * 100_000_000 - - let upperMultNum = isqrt(PRECISION + halfWidthScaled) - let lowerMultNum = isqrt(PRECISION - halfWidthScaled) - - var sqrtPriceUpper = targetSqrtPriceX96 * upperMultNum / SQRT_PRECISION - var sqrtPriceLower = targetSqrtPriceX96 * lowerMultNum / SQRT_PRECISION - - let MAX_SQRT: UInt256 = 1461446703485210103287273052203988822378723970341 - let MIN_SQRT: UInt256 = 4295128739 - if sqrtPriceUpper > MAX_SQRT { sqrtPriceUpper = MAX_SQRT } - if sqrtPriceLower < MIN_SQRT + 1 { sqrtPriceLower = MIN_SQRT + 1 } - - let rawTickUpper = getTickAtSqrtRatio(sqrtPriceX96: sqrtPriceUpper) - let rawTickLower = getTickAtSqrtRatio(sqrtPriceX96: sqrtPriceLower) - - // Align tickLower down, tickUpper up to tickSpacing - tickLower = rawTickLower / tickSpacing * tickSpacing - if rawTickLower < 0 && rawTickLower % tickSpacing != 0 { + // Align lower down, upper up to tick spacing + tickLower = rawLower / tickSpacing * tickSpacing + if rawLower < 0 && rawLower % tickSpacing != 0 { tickLower = tickLower - tickSpacing } - tickUpper = rawTickUpper / tickSpacing * tickSpacing - if rawTickUpper > 0 && rawTickUpper % tickSpacing != 0 { + tickUpper = rawUpper / tickSpacing * tickSpacing + if rawUpper > 0 && rawUpper % tickSpacing != 0 { tickUpper = tickUpper + tickSpacing } + assert(tickLower < tickUpper, message: "Tick range is empty after alignment") - assert(tickLower < tickUpper, message: "Concentrated tick range is empty after alignment") - + // Step 2: Compute liquidity and token balances from TVL let sqrtPa = getSqrtRatioAtTick(tick: tickLower) let sqrtPb = getSqrtRatioAtTick(tick: tickUpper) - // Convert TVL/2 from USD to token1 smallest units using token prices let effectiveBPrice = tokenBPriceUSD > 0.0 ? tokenBPriceUSD : 1.0 var token1PriceUSD = effectiveBPrice if tokenAAddress >= tokenBAddress { - // token1 = tokenA; tokenA is worth priceTokenBPerTokenA * tokenBPrice in USD token1PriceUSD = UFix64(priceTokenBPerTokenA) * effectiveBPrice } - let tvlHalfToken1 = tvl / 2.0 / token1PriceUSD - let tvlHalfWhole = UInt256(UInt64(tvlHalfToken1)) - var tvlHalfSmallest = tvlHalfWhole + let fraction = tvlFraction > 0.0 ? tvlFraction : 1.0 + let tvlHalfToken1 = tvl / 2.0 * fraction / token1PriceUSD + var tvlHalfSmallest = UInt256(UInt64(tvlHalfToken1)) var td: UInt8 = 0 while td < token1Decimals { tvlHalfSmallest = tvlHalfSmallest * 10 td = td + 1 } - // L = tvlHalfSmallest * Q96 / (sqrtP - sqrtPa) let sqrtPDiffA = targetSqrtPriceX96 - sqrtPa assert(sqrtPDiffA > 0, message: "sqrtP must be > sqrtPa for liquidity calculation") liquidityAmount = tvlHalfSmallest * Q96 / sqrtPDiffA - // token1 = L * (sqrtP - sqrtPa) / Q96 token1Balance = liquidityAmount * sqrtPDiffA / Q96 - - // token0 = L * (sqrtPb - sqrtP) / sqrtPb * Q96 / sqrtP let sqrtPDiffB = sqrtPb - targetSqrtPriceX96 token0Balance = liquidityAmount * sqrtPDiffB / sqrtPb * Q96 / targetSqrtPriceX96 } else { @@ -798,18 +732,6 @@ access(all) fun bytesToUInt256(_ bytes: [UInt8]): UInt256 { return result } -/// Integer square root via Newton's method. Returns floor(sqrt(x)). -access(all) fun isqrt(_ x: UInt256): UInt256 { - if x == 0 { return 0 } - var z = x - var y = (z + 1) / 2 - while y < z { - z = y - y = (z + x / z) / 2 - } - return z -} - access(all) fun getTokenDecimals(evmContractAddress: EVM.EVMAddress): UInt8 { let zeroAddress = EVM.addressFromString("0x0000000000000000000000000000000000000000") let callResult = EVM.dryCall(