diff --git a/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc b/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc index 8748dd8e..9db5f838 100644 --- a/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc +++ b/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc @@ -58,6 +58,19 @@ access(all) contract FlowYieldVaultsAutoBalancers { return nil } + /// Creates a sink to an AutoBalancer for external deposits (e.g., cancel deferred redemption). + /// + /// @param id: The yield vault/AutoBalancer ID + /// @return Sink that can deposit to the AutoBalancer, or nil if not found + /// + access(account) fun createExternalSink(id: UInt64): {DeFiActions.Sink}? { + let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath + if let autoBalancer = self.account.storage.borrow(from: storagePath) { + return autoBalancer.createBalancerSink() + } + return nil + } + /// Checks if an AutoBalancer has at least one active (Scheduled) transaction. /// Used by Supervisor to detect stuck yield vaults that need recovery. /// diff --git a/cadence/contracts/PMStrategiesV1.cdc b/cadence/contracts/PMStrategiesV1.cdc index 6574c461..eb045d41 100644 --- a/cadence/contracts/PMStrategiesV1.cdc +++ b/cadence/contracts/PMStrategiesV1.cdc @@ -20,6 +20,12 @@ import "FlowEVMBridgeUtils" import "EVMAmountUtils" // live oracles import "ERC4626PriceOracles" +// deferred redemption +import "FlowTransactionScheduler" +import "FlowEVMBridge" +import "FlowToken" +import "ScopedFTProviders" +import "FungibleTokenMetadataViews" /// PMStrategiesV1 /// @@ -34,6 +40,25 @@ import "ERC4626PriceOracles" /// access(all) contract PMStrategiesV1 { + access(all) event RedeemRequested( + yieldVaultID: UInt64, + userAddress: Address, + shares: UFix64, + estimatedAssets: UFix64, + vaultEVMAddressHex: String + ) + access(all) event RedeemClaimed( + yieldVaultID: UInt64, + userAddress: Address, + assetsReceivedEVM: UInt256, + vaultEVMAddressHex: String + ) + access(all) event RedeemCancelled( + yieldVaultID: UInt64, + userAddress: Address, + vaultEVMAddressHex: String + ) + access(all) let univ3FactoryEVMAddress: EVM.EVMAddress access(all) let univ3RouterEVMAddress: EVM.EVMAddress access(all) let univ3QuoterEVMAddress: EVM.EVMAddress @@ -106,8 +131,13 @@ access(all) contract PMStrategiesV1 { let availableBalance = self.availableBalance(ofToken: collateralType) return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) } - /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer + /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer. + /// Panics if a deferred redemption is pending — shares would be stranded on EVM. access(contract) fun burnCallback() { + assert( + PMStrategiesV1.getPendingRedeemInfo(yieldVaultID: self.id()!) == nil, + message: "Cannot close vault \(self.id()!) with pending deferred redemption" + ) FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) } access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { @@ -190,8 +220,13 @@ access(all) contract PMStrategiesV1 { let availableBalance = self.availableBalance(ofToken: collateralType) return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) } - /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer + /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer. + /// Panics if a deferred redemption is pending — shares would be stranded on EVM. access(contract) fun burnCallback() { + assert( + PMStrategiesV1.getPendingRedeemInfo(yieldVaultID: self.id()!) == nil, + message: "Cannot close vault \(self.id()!) with pending deferred redemption" + ) FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) } access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { @@ -601,56 +636,628 @@ access(all) contract PMStrategiesV1 { access(contract) fun _navBalanceFor(strategyType: Type, collateralType: Type, ofToken: Type, id: UInt64): UFix64 { if ofToken != collateralType { return 0.0 } - let ab = FlowYieldVaultsAutoBalancers.borrowAutoBalancer(id: id) - if ab == nil { return 0.0 } - let sharesBalance = ab!.vaultBalance() - if sharesBalance == 0.0 { return 0.0 } + var nav = 0.0 - let vaultAddr = self._getYieldTokenEVMAddress(forStrategy: strategyType, collateralType: collateralType) - ?? panic("No EVM vault address configured for \(strategyType.identifier)") + if let ab = FlowYieldVaultsAutoBalancers.borrowAutoBalancer(id: id) { + let sharesBalance = ab.vaultBalance() + if sharesBalance > 0.0 { + let vaultAddr = self._getYieldTokenEVMAddress(forStrategy: strategyType, collateralType: collateralType) + ?? panic("No EVM vault address configured for \(strategyType.identifier)") - let sharesWei = FlowEVMBridgeUtils.ufix64ToUInt256( - value: sharesBalance, - decimals: FlowEVMBridgeUtils.getTokenDecimals(evmContractAddress: vaultAddr) - ) + let sharesWei = FlowEVMBridgeUtils.ufix64ToUInt256( + value: sharesBalance, + decimals: FlowEVMBridgeUtils.getTokenDecimals(evmContractAddress: vaultAddr) + ) + + let navWei = ERC4626Utils.convertToAssets(vault: vaultAddr, shares: sharesWei) + ?? panic("convertToAssets failed for vault ".concat(vaultAddr.toString())) - let navWei = ERC4626Utils.convertToAssets(vault: vaultAddr, shares: sharesWei) - ?? panic("convertToAssets failed for vault ".concat(vaultAddr.toString())) + let assetAddr = ERC4626Utils.underlyingAssetEVMAddress(vault: vaultAddr) + ?? panic("No underlying asset EVM address found for vault \(vaultAddr.toString())") - let assetAddr = ERC4626Utils.underlyingAssetEVMAddress(vault: vaultAddr) - ?? panic("No underlying asset EVM address found for vault \(vaultAddr.toString())") + nav = EVMAmountUtils.toCadenceOutForToken(navWei, erc20Address: assetAddr) + } + } - return EVMAmountUtils.toCadenceOutForToken(navWei, erc20Address: assetAddr) + return nav + self.getPendingRedeemNAVBalance(yieldVaultID: id) } - /// Returns the COA capability for this account + /// Returns the COA capability for this account, issuing once and storing for reuse. /// TODO: this is temporary until we have a better way to pass user's COAs to inner connectors access(self) fun _getCOACapability(): Capability { - let coaCap = self.account.capabilities.storage.issue(/storage/evm) - assert(coaCap.check(), message: "Could not issue COA capability") - return coaCap + let capPath = /storage/strategiesCOACap + if self.account.storage.type(at: capPath) == nil { + let coaCap = self.account.capabilities.storage.issue(/storage/evm) + assert(coaCap.check(), message: "Could not issue COA capability") + self.account.storage.save(coaCap, to: capPath) + } + return self.account.storage.copy>(from: capPath) + ?? panic("Could not load COA capability from storage") } - /// Returns a FungibleTokenConnectors.VaultSinkAndSource used to subsidize cross VM token movement in contract- - /// defined strategies. + /// Returns the FlowToken vault capability for fee payment, issuing once and storing for reuse. access(self) - fun _createFeeSource(withID: DeFiActions.UniqueIdentifier?): {DeFiActions.Sink, DeFiActions.Source} { + fun _getFeeSourceCap(): Capability { let capPath = /storage/strategiesFeeSource if self.account.storage.type(at: capPath) == nil { let cap = self.account.capabilities.storage.issue(/storage/flowTokenVault) self.account.storage.save(cap, to: capPath) } - let vaultCap = self.account.storage.copy>(from: capPath) - ?? panic("Could not find fee source Capability at \(capPath)") + return self.account.storage.copy>(from: capPath) + ?? panic("Could not load fee source capability") + } + + /// Returns a FungibleTokenConnectors.VaultSinkAndSource used to subsidize cross VM token movement in contract- + /// defined strategies. + access(self) + fun _createFeeSource(withID: DeFiActions.UniqueIdentifier?): {DeFiActions.Sink, DeFiActions.Source} { return FungibleTokenConnectors.VaultSinkAndSource( min: nil, max: nil, - vault: vaultCap, + vault: self._getFeeSourceCap(), uniqueID: withID ) } + // ────────────────────────────────────────────────────────────────────── + // EVM helpers (More Vaults Diamond VaultFacet) + // ────────────────────────────────────────────────────────────────────── + + access(self) fun _evmRequestRedeem(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, vault: EVM.EVMAddress, shares: UInt256) { + let res = coa.call( + to: vault, + data: EVM.encodeABIWithSignature("requestRedeem(uint256)", [shares]), + gasLimit: 15_000_000, + value: EVM.Balance(attoflow: 0) + ) + assert(res.status == EVM.Status.successful, message: "requestRedeem failed: status \(res.status.rawValue)") + } + + access(self) fun _evmClearRequest(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, vault: EVM.EVMAddress) { + let res = coa.call( + to: vault, + data: EVM.encodeABIWithSignature("clearRequest()", [] as [AnyStruct]), + gasLimit: 15_000_000, + value: EVM.Balance(attoflow: 0) + ) + assert(res.status == EVM.Status.successful, message: "clearRequest failed: status \(res.status.rawValue)") + } + + access(self) fun _evmApprove(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, token: EVM.EVMAddress, spender: EVM.EVMAddress, amount: UInt256) { + let res = coa.call( + to: token, + data: EVM.encodeABIWithSignature("approve(address,uint256)", [spender, amount]), + gasLimit: 15_000_000, + value: EVM.Balance(attoflow: 0) + ) + assert(res.status == EVM.Status.successful, message: "approve failed: status \(res.status.rawValue)") + } + + access(self) fun _evmRedeem( + coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, + vault: EVM.EVMAddress, + shares: UInt256, + receiver: EVM.EVMAddress, + owner: EVM.EVMAddress + ): UInt256 { + let res = coa.call( + to: vault, + data: EVM.encodeABIWithSignature("redeem(uint256,address,address)", [shares, receiver, owner]), + gasLimit: 15_000_000, + value: EVM.Balance(attoflow: 0) + ) + assert(res.status == EVM.Status.successful, message: "redeem failed: status \(res.status.rawValue)") + let decoded = EVM.decodeABI(types: [Type()], data: res.data) + return decoded[0] as! UInt256 + } + + access(self) fun _evmGetWithdrawalTimelock(vault: EVM.EVMAddress): UInt64? { + let coa = self.account.storage.borrow<&EVM.CadenceOwnedAccount>(from: /storage/evm) + ?? panic("No COA at /storage/evm for view call") + let res = coa.dryCall( + to: vault, + data: EVM.encodeABIWithSignature("getWithdrawalTimelock()", [] as [AnyStruct]), + gasLimit: 5_000_000, + value: EVM.Balance(attoflow: 0) + ) + if res.status != EVM.Status.successful || res.data.length == 0 { + return nil + } + let decoded = EVM.decodeABI(types: [Type()], data: res.data) + return UInt64(decoded[0] as! UInt256) + } + + // ────────────────────────────────────────────────────────────────────── + // Deferred Redemption (More Vaults withdrawal queue) + // ────────────────────────────────────────────────────────────────────── + + /// Tracks a pending standard withdrawal waiting for timelock expiry. + access(all) struct PendingRedeemInfo { + access(all) let sharesEVM: UInt256 + access(all) let userCOAEVMAddress: EVM.EVMAddress + access(all) let userFlowAddress: Address + access(all) let vaultEVMAddress: EVM.EVMAddress + access(all) let metadata: {String: AnyStruct} + + init( + sharesEVM: UInt256, + userCOAEVMAddress: EVM.EVMAddress, + userFlowAddress: Address, + vaultEVMAddress: EVM.EVMAddress + ) { + self.sharesEVM = sharesEVM + self.userCOAEVMAddress = userCOAEVMAddress + self.userFlowAddress = userFlowAddress + self.vaultEVMAddress = vaultEVMAddress + self.metadata = {} + } + } + + /// Single handler resource stored in the contract account. Multiple scheduled claims + /// share this handler via capability; each schedule's data payload identifies which vault to process. + access(all) resource PendingRedeemHandler: FlowTransactionScheduler.TransactionHandler { + /// Keyed by yieldVaultID. Each user's YieldVault has a globally unique ID. + access(contract) let pendingRedeems: {UInt64: PendingRedeemInfo} + /// Keyed by yieldVaultID. Holds scheduled claim resources for status queries and cancellation. + access(contract) let scheduledTxns: @{UInt64: FlowTransactionScheduler.ScheduledTransaction} + /// Safety margin added after the EVM timelock to ensure the claim executes after expiry. + access(all) var schedulerBufferSeconds: UFix64 + /// Extensibility. + access(all) let metadata: {String: AnyStruct} + + init() { + self.pendingRedeems = {} + self.scheduledTxns <- {} + self.schedulerBufferSeconds = 30.0 + self.metadata = {} + } + + access(Configure) fun setSchedulerBufferSeconds(_ seconds: UFix64) { + self.schedulerBufferSeconds = seconds + } + + access(all) view fun getViews(): [Type] { return [] } + access(all) fun resolveView(_ view: Type): AnyStruct? { return nil } + + /// Called by FlowTransactionScheduler when the timelock expires. + /// No-ops gracefully if the pending redeem was already cleared. + access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { + let dataDict = data as? {String: AnyStruct} + ?? panic("PendingRedeemHandler: invalid data format") + let yieldVaultID = dataDict["yieldVaultID"] as? UInt64 + ?? panic("PendingRedeemHandler: missing yieldVaultID in data") + + if self.pendingRedeems[yieldVaultID] == nil { + return + } + PMStrategiesV1._claimRedeem(yieldVaultID: yieldVaultID) + } + + access(contract) fun setPendingRedeem(id: UInt64, info: PendingRedeemInfo) { + self.pendingRedeems[id] = info + } + + access(contract) fun removePendingRedeem(id: UInt64) { + self.pendingRedeems.remove(key: id) + } + + access(contract) view fun getPendingRedeem(id: UInt64): PendingRedeemInfo? { + return self.pendingRedeems[id] + } + + access(contract) fun setScheduledTx(id: UInt64, tx: @FlowTransactionScheduler.ScheduledTransaction) { + if let old <- self.scheduledTxns.remove(key: id) { + destroy old + } + self.scheduledTxns[id] <-! tx + } + + access(contract) fun removeScheduledTx(id: UInt64) { + if let tx <- self.scheduledTxns.remove(key: id) { + // Properly cancel with the scheduler if still scheduled; otherwise just destroy + if tx.status() == FlowTransactionScheduler.Status.Scheduled { + destroy FlowTransactionScheduler.cancel(scheduledTx: <-tx) + } else { + destroy tx + } + } + } + + access(contract) view fun getScheduledClaim(id: UInt64): &FlowTransactionScheduler.ScheduledTransaction? { + return &self.scheduledTxns[id] + } + + access(contract) view fun getAllPendingRedeemIDs(): [UInt64] { + return self.pendingRedeems.keys + } + } + + /// Computes the storage path for the PendingRedeemHandler. + access(self) view fun _pendingRedeemHandlerPath(): StoragePath { + return StoragePath(identifier: "PMStrategiesV1PendingRedeemHandler")! + } + + access(self) view fun _handlerCapStoragePath(): StoragePath { + return StoragePath(identifier: "PMStrategiesV1PendingRedeemHandlerCap")! + } + + /// Borrows the PendingRedeemHandler from contract account storage, or nil if not yet initialized. + access(self) view fun _borrowHandler(): &PendingRedeemHandler? { + return self.account.storage.borrow<&PendingRedeemHandler>(from: self._pendingRedeemHandlerPath()) + } + + /// Returns the reusable handler capability for FlowTransactionScheduler, issuing once on first call. + access(self) fun _getHandlerSchedulerCap(): Capability { + let capPath = self._handlerCapStoragePath() + if self.account.storage.type(at: capPath) == nil { + let cap = self.account.capabilities.storage.issue< + auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler} + >(self._pendingRedeemHandlerPath()) + self.account.storage.save(cap, to: capPath) + } + return self.account.storage.copy< + Capability + >(from: capPath) + ?? panic("Could not load handler capability from storage") + } + + /// Creates a ScopedFTProvider for bridge fee payment from the contract's FlowToken vault. + access(self) fun _createBridgeFeeProvider(): @ScopedFTProviders.ScopedFTProvider { + return <- ScopedFTProviders.createScopedFTProvider( + provider: self._getFeeSourceCap(), + filters: [ScopedFTProviders.AllowanceFilter(FlowEVMBridgeUtils.calculateBridgeFee(bytes: 400_000))], + expiration: getCurrentBlock().timestamp + 1.0 + ) + } + + /// Initiates a deferred redemption: converts underlying amount to shares on-chain, + /// withdraws yield tokens from AutoBalancer, bridges to user's COA, calls + /// requestRedeem + approve on EVM, records pending state, and schedules automated claim. + /// + /// @param yieldVaultID The user's YieldVault ID (also the AutoBalancer ID) + /// @param amount Underlying asset amount to redeem (e.g., FLOW). Nil = redeem all. + /// @param userCOA User's CadenceOwnedAccount reference for EVM calls + /// @param userFlowAddress User's Flow address for claim delivery + /// @param fees FlowToken vault to pay FlowTransactionScheduler scheduling fees + access(all) fun requestRedeem( + yieldVaultID: UInt64, + amount: UFix64?, + userCOA: auth(EVM.Call) &EVM.CadenceOwnedAccount, + userFlowAddress: Address, + fees: @FlowToken.Vault + ) { + pre { + fees.balance > 0.0: "Scheduling fees must be provided" + } + let handler = self._borrowHandler() + ?? panic("PendingRedeemHandler not initialized") + assert(handler.getPendingRedeem(id: yieldVaultID) == nil, message: "Pending redeem already exists for vault \(yieldVaultID)") + + // Validate vault ownership: the user at userFlowAddress must own this YieldVault + let managerRef = getAccount(userFlowAddress).capabilities.borrow<&FlowYieldVaults.YieldVaultManager>( + FlowYieldVaults.YieldVaultManagerPublicPath + ) ?? panic("User has no YieldVaultManager") + let yieldVault = managerRef.borrowYieldVault(id: yieldVaultID) + ?? panic("User does not own vault \(yieldVaultID)") + + // Derive the vault EVM address from on-chain strategy config + let strategyType = CompositeType(yieldVault.getStrategyType()) + ?? panic("Invalid strategy type \(yieldVault.getStrategyType())") + let collateralType = CompositeType(yieldVault.getVaultTypeIdentifier()) + ?? panic("Invalid collateral type \(yieldVault.getVaultTypeIdentifier())") + let vaultEVMAddress = self._getYieldTokenEVMAddress(forStrategy: strategyType, collateralType: collateralType) + ?? panic("No EVM vault address configured for \(strategyType.identifier)") + + // Validate COA ownership: the provided COA must belong to userFlowAddress + let publicCOA = getAccount(userFlowAddress).capabilities + .borrow<&EVM.CadenceOwnedAccount>(/public/evm) + ?? panic("User has no public COA at /public/evm") + assert( + publicCOA.address().bytes == userCOA.address().bytes, + message: "Provided COA does not belong to user at \(userFlowAddress)" + ) + + let source = FlowYieldVaultsAutoBalancers.createExternalSource(id: yieldVaultID) + ?? panic("Could not create external source for vault \(yieldVaultID)") + + // Convert underlying amount to target shares, or withdraw all if nil + var targetShares = source.minimumAvailable() + if let underlyingAmount = amount { + let underlyingAddress = ERC4626Utils.underlyingAssetEVMAddress(vault: vaultEVMAddress) + ?? panic("Could not get underlying asset address") + let assetsEVM = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount( + underlyingAmount, erc20Address: underlyingAddress + ) + let targetSharesEVM = ERC4626Utils.convertToShares(vault: vaultEVMAddress, assets: assetsEVM) + ?? panic("convertToShares failed") + targetShares = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount( + targetSharesEVM, erc20Address: vaultEVMAddress + ) + } + + // 1. Withdraw yield tokens from AutoBalancer + let yieldTokenVault <- source.withdrawAvailable(maxAmount: targetShares) + let shares = yieldTokenVault.balance + assert(shares > 0.0, message: "No shares available to redeem") + assert( + amount == nil || shares >= targetShares * 0.9999, + message: "Insufficient shares for requested amount: got \(shares), need >= \(targetShares * 0.9999)" + ) + + // 2. Bridge yield tokens from Cadence to user's COA on EVM + let scopedProvider <- self._createBridgeFeeProvider() + FlowEVMBridge.bridgeTokensToEVM( + vault: <-yieldTokenVault, + to: userCOA.address(), + feeProvider: &scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + destroy scopedProvider + + // 3. Convert actual withdrawn shares to EVM and call requestRedeem + let sharesEVM = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(shares, erc20Address: vaultEVMAddress) + self._evmRequestRedeem(coa: userCOA, vault: vaultEVMAddress, shares: sharesEVM) + + // 4. Approve service COA to redeem on user's behalf + let serviceCOA = self._getCOACapability().borrow() + ?? panic("Could not borrow service COA") + self._evmApprove(coa: userCOA, token: vaultEVMAddress, spender: serviceCOA.address(), amount: sharesEVM) + + // 5. Record pending redeem + handler.setPendingRedeem(id: yieldVaultID, info: PendingRedeemInfo( + sharesEVM: sharesEVM, + userCOAEVMAddress: userCOA.address(), + userFlowAddress: userFlowAddress, + vaultEVMAddress: vaultEVMAddress + )) + + // 6. Schedule automated claim after timelock expires. + // FlowTransactionScheduler.Scheduled event carries the exact execution timestamp for backend ingestion. + let timelockSeconds = self._evmGetWithdrawalTimelock(vault: vaultEVMAddress) + ?? panic("Could not query withdrawal timelock") + + let scheduledTx <- FlowTransactionScheduler.schedule( + handlerCap: self._getHandlerSchedulerCap(), + data: {"yieldVaultID": yieldVaultID}, + timestamp: getCurrentBlock().timestamp + UFix64(timelockSeconds) + handler.schedulerBufferSeconds, + priority: FlowTransactionScheduler.Priority.Low, + executionEffort: 2500, // Priority.Low max; claim involves EVM redeem + unwrap/bridge + Cadence deposit + fees: <-fees + ) + + handler.setScheduledTx(id: yieldVaultID, tx: <-scheduledTx) + + // Estimate underlying assets for the redeemed shares + let underlyingAddress = ERC4626Utils.underlyingAssetEVMAddress(vault: vaultEVMAddress) + ?? panic("Could not get underlying asset address") + var estimatedAssets = 0.0 + if let assetsEVM = ERC4626Utils.previewRedeem(vault: vaultEVMAddress, shares: sharesEVM) { + estimatedAssets = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount( + assetsEVM, erc20Address: underlyingAddress + ) + } + + emit RedeemRequested( + yieldVaultID: yieldVaultID, + userAddress: userFlowAddress, + shares: shares, + estimatedAssets: estimatedAssets, + vaultEVMAddressHex: vaultEVMAddress.toString() + ) + } + + /// Called by PendingRedeemHandler.executeTransaction when the timelock has expired. + /// Redeems shares via service COA, converts underlying ERC-20 to Cadence, deposits to user's wallet. + access(self) fun _claimRedeem(yieldVaultID: UInt64) { + let handler = self._borrowHandler() + ?? panic("PendingRedeemHandler not initialized") + let info = handler.getPendingRedeem(id: yieldVaultID) + ?? panic("No pending redeem for vault \(yieldVaultID)") + + let coa = self._getCOACapability().borrow() + ?? panic("Could not borrow service COA") + + // 1. Redeem: service COA calls redeem(shares, receiver=serviceCOA, owner=userCOA) + let assetsReceived = self._evmRedeem( + coa: coa, + vault: info.vaultEVMAddress, + shares: info.sharesEVM, + receiver: coa.address(), + owner: info.userCOAEVMAddress + ) + + // 2. Convert underlying ERC-20 tokens from EVM to Cadence and deliver to user + let underlyingAddress = ERC4626Utils.underlyingAssetEVMAddress(vault: info.vaultEVMAddress) + ?? panic("Could not get underlying asset address") + + let wflowAddress = FlowEVMBridgeConfig.getEVMAddressAssociated(with: Type<@FlowToken.Vault>()) + if wflowAddress != nil && underlyingAddress.bytes == wflowAddress!.bytes { + let unwrapResult = coa.call( + to: underlyingAddress, + data: EVM.encodeABIWithSignature("withdraw(uint256)", [assetsReceived]), + gasLimit: 15_000_000, + value: EVM.Balance(attoflow: 0) + ) + assert(unwrapResult.status == EVM.Status.successful, message: "WFLOW unwrap failed") + let flowVault <- coa.withdraw(balance: EVM.Balance(attoflow: UInt(assetsReceived))) + let receiver = getAccount(info.userFlowAddress).capabilities + .borrow<&{FungibleToken.Receiver}>(/public/flowTokenReceiver) + ?? panic("Could not borrow user's FlowToken Receiver at \(info.userFlowAddress)") + receiver.deposit(from: <-flowVault) + } else { + let underlyingCadenceType = FlowEVMBridgeConfig.getTypeAssociated(with: underlyingAddress) + ?? panic("No Cadence type for underlying EVM address \(underlyingAddress.toString())") + let bridgeFeeProvider <- self._createBridgeFeeProvider() + let tokenVault <- coa.withdrawTokens( + type: underlyingCadenceType, + amount: assetsReceived, + feeProvider: &bridgeFeeProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + destroy bridgeFeeProvider + + let vaultType = tokenVault.getType() + let tokenContract = getAccount(vaultType.address!) + .contracts.borrow<&{FungibleToken}>(name: vaultType.contractName!) + ?? panic("Could not borrow FungibleToken contract for \(vaultType.identifier)") + let vaultData = tokenContract.resolveContractView( + resourceType: vaultType, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not resolve FTVaultData for \(vaultType.identifier)") + let receiver = getAccount(info.userFlowAddress).capabilities + .borrow<&{FungibleToken.Receiver}>(vaultData.receiverPath) + ?? panic("User has no receiver for \(vaultType.identifier) at \(info.userFlowAddress)") + receiver.deposit(from: <-tokenVault) + } + + // 3. Cleanup + emit RedeemClaimed( + yieldVaultID: yieldVaultID, + userAddress: info.userFlowAddress, + assetsReceivedEVM: assetsReceived, + vaultEVMAddressHex: info.vaultEVMAddress.toString() + ) + handler.removePendingRedeem(id: yieldVaultID) + handler.removeScheduledTx(id: yieldVaultID) + } + + /// Cancels a pending deferred redemption: clears the EVM request, transfers shares back + /// from user's COA to service COA, bridges back to Cadence, deposits to AutoBalancer. + /// + /// @param yieldVaultID The user's YieldVault ID + /// @param userCOA User's CadenceOwnedAccount reference for EVM calls + access(all) fun clearRedeemRequest( + yieldVaultID: UInt64, + userCOA: auth(EVM.Call) &EVM.CadenceOwnedAccount + ) { + let handler = self._borrowHandler() + ?? panic("PendingRedeemHandler not initialized") + let info = handler.getPendingRedeem(id: yieldVaultID) + ?? panic("No pending redeem for vault \(yieldVaultID)") + + assert( + userCOA.address().bytes == info.userCOAEVMAddress.bytes, + message: "COA address does not match pending redeem requester" + ) + + // Extra safety: verify the COA is published by the original requester's Flow account + let publicCOA = getAccount(info.userFlowAddress).capabilities + .borrow<&EVM.CadenceOwnedAccount>(/public/evm) + ?? panic("Original requester has no public COA at /public/evm") + assert( + publicCOA.address().bytes == userCOA.address().bytes, + message: "Provided COA does not belong to original requester at \(info.userFlowAddress)" + ) + + // 1. Clear request on EVM. + // Note: we rely on the EVM revert (assert status==successful) to confirm the clear. + self._evmClearRequest(coa: userCOA, vault: info.vaultEVMAddress) + + // 2. Transfer shares from user's COA back to service COA via ERC-20 transfer + let serviceCOA = self._getCOACapability().borrow() + ?? panic("Could not borrow service COA") + let transferResult = userCOA.call( + to: info.vaultEVMAddress, + data: EVM.encodeABIWithSignature( + "transfer(address,uint256)", + [serviceCOA.address(), info.sharesEVM] + ), + gasLimit: 15_000_000, + value: EVM.Balance(attoflow: 0) + ) + assert(transferResult.status == EVM.Status.successful, message: "Share transfer back to service COA failed") + + // 2b. Revoke lingering ERC-20 approval (transfer doesn't consume allowance) + self._evmApprove(coa: userCOA, token: info.vaultEVMAddress, spender: serviceCOA.address(), amount: 0) + + // 3. Bridge shares from service COA back to Cadence + let yieldTokenType = FlowEVMBridgeConfig.getTypeAssociated(with: info.vaultEVMAddress) + ?? panic("Could not resolve Cadence type for vault \(info.vaultEVMAddress.toString())") + let scopedProvider <- self._createBridgeFeeProvider() + let yieldTokenVault <- serviceCOA.withdrawTokens( + type: yieldTokenType, + amount: info.sharesEVM, + feeProvider: &scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + destroy scopedProvider + + // 4. Deposit back to AutoBalancer + let sink = FlowYieldVaultsAutoBalancers.createExternalSink(id: yieldVaultID) + ?? panic("Could not create external sink for vault \(yieldVaultID)") + sink.depositCapacity(from: &yieldTokenVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) + assert(yieldTokenVault.balance == 0.0, message: "Yield tokens should be fully deposited back") + destroy yieldTokenVault + + // 5. Cancel scheduled transaction and cleanup + emit RedeemCancelled( + yieldVaultID: yieldVaultID, + userAddress: info.userFlowAddress, + vaultEVMAddressHex: info.vaultEVMAddress.toString() + ) + handler.removeScheduledTx(id: yieldVaultID) + handler.removePendingRedeem(id: yieldVaultID) + } + + /// Returns the NAV value of pending redeem shares for a yield vault, or 0 if none. + /// Converts shares → underlying via ERC-4626 convertToAssets, matching the same + /// conversion used by _navBalanceFor (which calls this function). + access(all) fun getPendingRedeemNAVBalance(yieldVaultID: UInt64): UFix64 { + if let handler = self._borrowHandler() { + if let info = handler.getPendingRedeem(id: yieldVaultID) { + let navWei = ERC4626Utils.convertToAssets(vault: info.vaultEVMAddress, shares: info.sharesEVM) + ?? panic("convertToAssets failed for pending redeem") + let assetAddr = ERC4626Utils.underlyingAssetEVMAddress(vault: info.vaultEVMAddress) + ?? panic("No underlying asset address for vault") + return EVMAmountUtils.toCadenceOutForToken(navWei, erc20Address: assetAddr) + } + } + return 0.0 + } + + /// Returns the full PendingRedeemInfo for a given yield vault, or nil if none. + access(all) view fun getPendingRedeemInfo(yieldVaultID: UInt64): PendingRedeemInfo? { + if let handler = self._borrowHandler() { + return handler.getPendingRedeem(id: yieldVaultID) + } + return nil + } + + /// Returns all yield vault IDs with active pending redeems; intended for operational audit and debugging. + access(all) view fun getAllPendingRedeemIDs(): [UInt64] { + if let handler = self._borrowHandler() { + return handler.getAllPendingRedeemIDs() + } + return [] + } + + /// Returns the scheduled claim transaction for a yield vault, or nil if none. + /// Callers can read .id, .timestamp, and .status() on the returned reference. + access(all) view fun getScheduledClaim(yieldVaultID: UInt64): &FlowTransactionScheduler.ScheduledTransaction? { + if let handler = self._borrowHandler() { + return handler.getScheduledClaim(id: yieldVaultID) + } + return nil + } + + /// Returns the current scheduler buffer (seconds added after EVM timelock), or nil if handler not initialized. + access(all) view fun getSchedulerBufferSeconds(): UFix64? { + if let handler = self._borrowHandler() { + return handler.schedulerBufferSeconds + } + return nil + } + + /// Initializes the PendingRedeemHandler. Must be called once via admin transaction + /// after contract update, before any deferred redemptions can be processed. + /// access(all) is safe: idempotent no-op when handler exists, writes only to contract's own storage. + access(all) fun initPendingRedeemHandler() { + let path = self._pendingRedeemHandlerPath() + if self.account.storage.type(at: path) != nil { + return + } + self.account.storage.save(<-create PendingRedeemHandler(), to: path) + } + init( univ3FactoryEVMAddress: String, univ3RouterEVMAddress: String, diff --git a/cadence/tests/PMStrategiesV1_FUSDEV_test.cdc b/cadence/tests/PMStrategiesV1_FUSDEV_test.cdc index f1b549c6..8a142a31 100644 --- a/cadence/tests/PMStrategiesV1_FUSDEV_test.cdc +++ b/cadence/tests/PMStrategiesV1_FUSDEV_test.cdc @@ -132,6 +132,14 @@ access(all) fun setup() { ) Test.expect(err, Test.beNil()) + log("Deploying FlowYieldVaultsAutoBalancers...") + err = Test.deployContract( + name: "FlowYieldVaultsAutoBalancers", + path: "../../cadence/contracts/FlowYieldVaultsAutoBalancers.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + // Redeploy PMStrategiesV1 with latest local code to override mainnet version log("Deploying PMStrategiesV1...") err = Test.deployContract( diff --git a/cadence/tests/PMStrategiesV1_deferred_redeem_test.cdc b/cadence/tests/PMStrategiesV1_deferred_redeem_test.cdc new file mode 100644 index 00000000..c2adc179 --- /dev/null +++ b/cadence/tests/PMStrategiesV1_deferred_redeem_test.cdc @@ -0,0 +1,471 @@ +#test_fork(network: "mainnet", height: nil) + +import Test + +/// Fork test for PMStrategiesV1 deferred redemption — validates request/query/cancel +/// against real mainnet EVM state (More Vaults Diamond vault with withdrawal queue). +/// +/// Tests: +/// 1. Create a syWFLOWv yield vault and deposit FLOW +/// 2. Initialize PendingRedeemHandler +/// 3. Request a deferred redemption (requestRedeem) with specific amount +/// 4. Query pending state (getPendingRedeemInfo, getPendingRedeemNAVBalance) +/// 5. Verify navBalance includes pending shares +/// 6. View functions while pending (getAllPendingRedeemIDs, getScheduledClaim, getSchedulerBufferSeconds) +/// 7. Negative: wrong COA on clearRedeemRequest, no-pending clearRedeemRequest +/// 8. Cancel the deferred redemption (clearRedeemRequest), verify state cleared +/// 9. Redeem all (nil amount) after cancel — exercises minimumAvailable() path, verifies lifecycle repeatability +/// +/// claimRedeem is not fork-testable: moveTime() advances block.timestamp past +/// the 48h timelock but EVM oracle/yield state stays frozen, causing redeem() to +/// revert on stale-data checks. Cadence-side scheduler logic verified separately. +/// +/// Mainnet addresses: +/// - Admin (FlowYieldVaults deployer): 0xb1d63873c3cc9f79 +/// - syWFLOWv (More Vaults Diamond): 0xCBf9a7753F9D2d0e8141ebB36d99f87AcEf98597 +/// - Withdrawal timelock: 172800s (48h) + +// --- Accounts --- + +access(all) let adminAccount = Test.getAccount(0xb1d63873c3cc9f79) +access(all) let userAccount = Test.getAccount(0x443472749ebdaac8) + +// --- Constants --- + +access(all) let syWFLOWvStrategyIdentifier = "A.b1d63873c3cc9f79.PMStrategiesV1.syWFLOWvStrategy" +access(all) let flowVaultIdentifier = "A.1654653399040a61.FlowToken.Vault" +access(all) let schedulingFee = 0.5 + +// --- Test State --- + +access(all) var yieldVaultID: UInt64 = 0 + +/* --- Helpers --- */ + +access(all) +fun _executeTransactionFile(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.TransactionResult { + let txn = Test.Transaction( + code: Test.readFile(path), + authorizers: signers.map(fun (s: Test.TestAccount): Address { return s.address }), + signers: signers, + arguments: args + ) + return Test.executeTransaction(txn) +} + +access(all) +fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { + return Test.executeScript(Test.readFile(path), args) +} + +access(all) +fun equalAmounts(a: UFix64, b: UFix64, tolerance: UFix64): Bool { + if a > b { + return a - b <= tolerance + } + return b - a <= tolerance +} + +/* --- Setup --- */ + +access(all) fun setup() { + log("==== PMStrategiesV1 Deferred Redeem Fork Test Setup ====") + + // Deploy FlowActions dependencies (latest local code) + log("Deploying EVMAmountUtils...") + var err = Test.deployContract( + name: "EVMAmountUtils", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/EVMAmountUtils.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying UniswapV3SwapConnectors...") + err = Test.deployContract( + name: "UniswapV3SwapConnectors", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/UniswapV3SwapConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying ERC4626Utils...") + err = Test.deployContract( + name: "ERC4626Utils", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/utils/ERC4626Utils.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "ERC4626SwapConnectors", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/ERC4626SwapConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "MorphoERC4626SinkConnectors", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/morpho/MorphoERC4626SinkConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "MorphoERC4626SwapConnectors", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + // Deploy updated FlowYieldVaults platform contracts + log("Deploying FlowYieldVaults...") + err = Test.deployContract( + name: "FlowYieldVaults", + path: "../../cadence/contracts/FlowYieldVaults.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying FlowYieldVaultsAutoBalancers...") + err = Test.deployContract( + name: "FlowYieldVaultsAutoBalancers", + path: "../../cadence/contracts/FlowYieldVaultsAutoBalancers.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying PMStrategiesV1...") + err = Test.deployContract( + name: "PMStrategiesV1", + path: "../../cadence/contracts/PMStrategiesV1.cdc", + arguments: [ + "0xca6d7Bb03334bBf135902e1d919a5feccb461632", + "0xeEDC6Ff75e1b10B903D9013c358e446a73d35341", + "0x370A8DF17742867a44e56223EC20D82092242C85" + ] + ) + Test.expect(err, Test.beNil()) + + // Grant beta access + log("Granting beta access...") + var result = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/grant_beta.cdc", + [], + [adminAccount, userAccount] + ) + Test.expect(result, Test.beSucceeded()) + + log("Initializing PendingRedeemHandler...") + result = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/init_pending_redeem_handler.cdc", + [], + [] + ) + Test.expect(result, Test.beSucceeded()) + + // Ensure user has a COA + log("Setting up user COA...") + let setupCOATxCode = "import \"EVM\"\ntransaction() {\n prepare(signer: auth(SaveValue, StorageCapabilities, PublishCapability) &Account) {\n if signer.storage.type(at: /storage/evm) == nil {\n signer.storage.save(<-EVM.createCadenceOwnedAccount(), to: /storage/evm)\n let cap = signer.capabilities.storage.issue<&EVM.CadenceOwnedAccount>(/storage/evm)\n signer.capabilities.publish(cap, at: /public/evm)\n }\n }\n}" + let setupCOATx = Test.Transaction( + code: setupCOATxCode, + authorizers: [userAccount.address], + signers: [userAccount], + arguments: [] + ) + result = Test.executeTransaction(setupCOATx) + Test.expect(result, Test.beSucceeded()) + + log("==== Setup Complete ====") +} + +/* --- Tests --- */ + +access(all) fun testCreateYieldVaultForDeferredRedeem() { + log("Creating syWFLOWv yield vault with 2.0 FLOW...") + let result = _executeTransactionFile( + "../transactions/flow-yield-vaults/create_yield_vault.cdc", + [syWFLOWvStrategyIdentifier, flowVaultIdentifier, 2.0], + [userAccount] + ) + Test.expect(result, Test.beSucceeded()) + + let idsResult = _executeScript( + "../scripts/flow-yield-vaults/get_yield_vault_ids.cdc", + [userAccount.address] + ) + Test.expect(idsResult, Test.beSucceeded()) + let ids = idsResult.returnValue! as! [UInt64]? + Test.assert(ids != nil && ids!.length > 0, message: "Expected at least one yield vault") + yieldVaultID = ids![ids!.length - 1] + log("Created yield vault ID: \(yieldVaultID)") + + let balResult = _executeScript( + "../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", + [userAccount.address, yieldVaultID] + ) + Test.expect(balResult, Test.beSucceeded()) + let balance = balResult.returnValue! as! UFix64? + Test.assert(balance != nil && balance! > 0.0, message: "Expected positive balance after deposit") + log("Vault balance: \(balance!)") +} + +access(all) fun testNoPendingRedeemInitially() { + let infoResult = _executeScript( + "scripts/pm-strategies/get_pending_redeem_info.cdc", + [yieldVaultID] + ) + Test.expect(infoResult, Test.beSucceeded()) + Test.assert(infoResult.returnValue == nil, message: "Expected no pending redeem initially") + + let navResult = _executeScript( + "scripts/pm-strategies/get_pending_redeem_nav_balance.cdc", + [yieldVaultID] + ) + Test.expect(navResult, Test.beSucceeded()) + let nav = navResult.returnValue! as! UFix64 + Test.assert(nav == 0.0, message: "Expected zero pending NAV initially") + log("No pending redeem initially — confirmed") +} + +access(all) fun testRequestRedeem() { + let navBefore = (_executeScript( + "scripts/pm-strategies/get_yield_vault_nav_balance.cdc", + [userAccount.address, yieldVaultID] + ).returnValue! as! UFix64?)! + log("NAV balance before requestRedeem: \(navBefore)") + + log("Requesting deferred redeem for 1.0 FLOW worth of shares...") + let result = _executeTransactionFile( + "transactions/pm-strategies/request_redeem.cdc", + [yieldVaultID, 1.0 as UFix64?, schedulingFee], + [userAccount] + ) + Test.expect(result, Test.beSucceeded()) + log("requestRedeem succeeded") + + // Verify pending state exists + let infoResult = _executeScript( + "scripts/pm-strategies/get_pending_redeem_info.cdc", + [yieldVaultID] + ) + Test.expect(infoResult, Test.beSucceeded()) + Test.assert(infoResult.returnValue != nil, message: "Expected pending redeem info after request") + log("Pending redeem info confirmed present") + + // Verify pending NAV > 0 + let navResult = _executeScript( + "scripts/pm-strategies/get_pending_redeem_nav_balance.cdc", + [yieldVaultID] + ) + Test.expect(navResult, Test.beSucceeded()) + let pendingNAV = navResult.returnValue! as! UFix64 + Test.assert(pendingNAV > 0.0, message: "Expected positive pending NAV") + log("Pending NAV: \(pendingNAV)") + + // NAV balance (via navBalance()) should include pending shares + let navAfter = (_executeScript( + "scripts/pm-strategies/get_yield_vault_nav_balance.cdc", + [userAccount.address, yieldVaultID] + ).returnValue! as! UFix64?)! + log("NAV balance after requestRedeem (should include pending): \(navAfter)") + Test.assert( + equalAmounts(a: navAfter, b: navBefore, tolerance: 0.05), + message: "NAV balance should still include pending shares" + ) + + // Available balance should be reduced (shares moved out of AutoBalancer) + let availAfter = (_executeScript( + "../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", + [userAccount.address, yieldVaultID] + ).returnValue! as! UFix64?)! + log("Available balance after requestRedeem: \(availAfter)") + Test.assert(availAfter < navBefore, message: "Available balance should drop after requestRedeem") +} + +access(all) fun testDuplicateRequestRedeemFails() { + log("Attempting duplicate requestRedeem (should fail)...") + let result = _executeTransactionFile( + "transactions/pm-strategies/request_redeem.cdc", + [yieldVaultID, 0.5 as UFix64?, schedulingFee], + [userAccount] + ) + Test.expect(result, Test.beFailed()) + log("Duplicate requestRedeem correctly rejected") +} + +access(all) fun testViewFunctionsWhilePending() { + // getAllPendingRedeemIDs — should contain exactly our yieldVaultID + let idsResult = _executeScript( + "scripts/pm-strategies/get_all_pending_redeem_ids.cdc", + [] + ) + Test.expect(idsResult, Test.beSucceeded()) + let ids = idsResult.returnValue! as! [UInt64] + Test.assert(ids.length == 1, message: "Expected exactly one pending redeem ID") + Test.assert(ids[0] == yieldVaultID, message: "Pending ID should match yieldVaultID") + log("getAllPendingRedeemIDs: \(yieldVaultID)") + + // getScheduledClaim — should return a future timestamp + let tsResult = _executeScript( + "scripts/pm-strategies/get_scheduled_claim_timestamp.cdc", + [yieldVaultID] + ) + Test.expect(tsResult, Test.beSucceeded()) + let ts = tsResult.returnValue! as! UFix64? + Test.assert(ts != nil, message: "Expected scheduled claim timestamp") + Test.assert(ts! > getCurrentBlock().timestamp, message: "Scheduled timestamp should be in the future") + log("getScheduledClaim timestamp: \(ts!)") + + // getSchedulerBufferSeconds — should be non-nil + let bufResult = _executeScript( + "scripts/pm-strategies/get_scheduler_buffer_seconds.cdc", + [] + ) + Test.expect(bufResult, Test.beSucceeded()) + let buf = bufResult.returnValue! as! UFix64? + Test.assert(buf != nil, message: "Expected scheduler buffer seconds") + log("getSchedulerBufferSeconds: \(buf!)") +} + +access(all) fun testClearRedeemRequestWrongCOAFails() { + log("Attempting clearRedeemRequest with admin COA (should fail)...") + let result = _executeTransactionFile( + "transactions/pm-strategies/clear_redeem_request.cdc", + [yieldVaultID], + [adminAccount] + ) + Test.expect(result, Test.beFailed()) + log("clearRedeemRequest with wrong COA correctly rejected") +} + +access(all) fun testClearRedeemRequest() { + let navBefore = (_executeScript( + "scripts/pm-strategies/get_yield_vault_nav_balance.cdc", + [userAccount.address, yieldVaultID] + ).returnValue! as! UFix64?)! + log("NAV balance before clearRedeemRequest: \(navBefore)") + + log("Clearing redeem request...") + let result = _executeTransactionFile( + "transactions/pm-strategies/clear_redeem_request.cdc", + [yieldVaultID], + [userAccount] + ) + Test.expect(result, Test.beSucceeded()) + log("clearRedeemRequest succeeded") + + // Pending state should be cleared + let infoResult = _executeScript( + "scripts/pm-strategies/get_pending_redeem_info.cdc", + [yieldVaultID] + ) + Test.expect(infoResult, Test.beSucceeded()) + Test.assert(infoResult.returnValue == nil, message: "Expected no pending redeem after clear") + + let navResult = _executeScript( + "scripts/pm-strategies/get_pending_redeem_nav_balance.cdc", + [yieldVaultID] + ) + Test.expect(navResult, Test.beSucceeded()) + let pendingNAV = navResult.returnValue! as! UFix64 + Test.assert(pendingNAV == 0.0, message: "Expected zero pending NAV after clear") + + // NAV balance should be preserved (shares returned to AutoBalancer) + let navAfter = (_executeScript( + "scripts/pm-strategies/get_yield_vault_nav_balance.cdc", + [userAccount.address, yieldVaultID] + ).returnValue! as! UFix64?)! + log("NAV balance after clearRedeemRequest: \(navAfter)") + Test.assert( + equalAmounts(a: navAfter, b: navBefore, tolerance: 0.05), + message: "NAV balance should be preserved after clearing redeem request" + ) + log("Shares restored to AutoBalancer — confirmed") + + // View functions should reflect cleared state + let idsResult = _executeScript( + "scripts/pm-strategies/get_all_pending_redeem_ids.cdc", + [] + ) + Test.expect(idsResult, Test.beSucceeded()) + let clearedIds = idsResult.returnValue! as! [UInt64] + Test.assert(clearedIds.length == 0, message: "Expected no pending redeem IDs after clear") + + let tsResult = _executeScript( + "scripts/pm-strategies/get_scheduled_claim_timestamp.cdc", + [yieldVaultID] + ) + Test.expect(tsResult, Test.beSucceeded()) + Test.assert(tsResult.returnValue == nil, message: "Expected no scheduled claim after clear") + log("View functions confirm cleared state") +} + +access(all) fun testClearRedeemRequestNoPendingFails() { + log("Attempting clearRedeemRequest with no pending redeem (should fail)...") + let result = _executeTransactionFile( + "transactions/pm-strategies/clear_redeem_request.cdc", + [yieldVaultID], + [userAccount] + ) + Test.expect(result, Test.beFailed()) + log("clearRedeemRequest with no pending redeem correctly rejected") +} + +access(all) fun testRedeemAllAfterCancel() { + let navBefore = (_executeScript( + "scripts/pm-strategies/get_yield_vault_nav_balance.cdc", + [userAccount.address, yieldVaultID] + ).returnValue! as! UFix64?)! + log("NAV before redeem-all: \(navBefore)") + + // Request redeem with nil amount (redeem all shares) + log("Requesting deferred redeem for ALL shares (nil amount)...") + var result = _executeTransactionFile( + "transactions/pm-strategies/request_redeem.cdc", + [yieldVaultID, nil as UFix64?, schedulingFee], + [userAccount] + ) + Test.expect(result, Test.beSucceeded()) + log("requestRedeem (all) succeeded") + + // Available balance should be ~0 (all shares moved to pending) + let availAfter = (_executeScript( + "../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", + [userAccount.address, yieldVaultID] + ).returnValue! as! UFix64?)! + Test.assert( + equalAmounts(a: availAfter, b: 0.0, tolerance: 0.01), + message: "Available balance should be ~0 after redeem-all" + ) + log("Available balance after redeem-all: \(availAfter)") + + // Pending NAV should approximate the full vault NAV + let pendingNAV = (_executeScript( + "scripts/pm-strategies/get_pending_redeem_nav_balance.cdc", + [yieldVaultID] + ).returnValue! as! UFix64) + Test.assert(pendingNAV > 0.0, message: "Expected positive pending NAV for redeem-all") + Test.assert( + equalAmounts(a: pendingNAV, b: navBefore, tolerance: 0.05), + message: "Pending NAV should approximate full vault NAV" + ) + log("Pending NAV (all): \(pendingNAV)") + + // Cancel to leave clean state + log("Cancelling re-request...") + result = _executeTransactionFile( + "transactions/pm-strategies/clear_redeem_request.cdc", + [yieldVaultID], + [userAccount] + ) + Test.expect(result, Test.beSucceeded()) + + let idsResult = _executeScript( + "scripts/pm-strategies/get_all_pending_redeem_ids.cdc", + [] + ) + Test.expect(idsResult, Test.beSucceeded()) + let clearedIds = idsResult.returnValue! as! [UInt64] + Test.assert(clearedIds.length == 0, message: "Expected no pending redeems after re-cancel") + log("Re-request → cancel lifecycle complete") +} diff --git a/cadence/tests/PMStrategiesV1_syWFLOWv_test.cdc b/cadence/tests/PMStrategiesV1_syWFLOWv_test.cdc index 7a181a20..6a198214 100644 --- a/cadence/tests/PMStrategiesV1_syWFLOWv_test.cdc +++ b/cadence/tests/PMStrategiesV1_syWFLOWv_test.cdc @@ -125,6 +125,14 @@ access(all) fun setup() { ) Test.expect(err, Test.beNil()) + log("Deploying FlowYieldVaultsAutoBalancers...") + err = Test.deployContract( + name: "FlowYieldVaultsAutoBalancers", + path: "../../cadence/contracts/FlowYieldVaultsAutoBalancers.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + // Redeploy PMStrategiesV1 with latest local code to override mainnet version log("Deploying PMStrategiesV1...") err = Test.deployContract( diff --git a/cadence/tests/scripts/pm-strategies/get_all_pending_redeem_ids.cdc b/cadence/tests/scripts/pm-strategies/get_all_pending_redeem_ids.cdc new file mode 100644 index 00000000..dfe6bcb7 --- /dev/null +++ b/cadence/tests/scripts/pm-strategies/get_all_pending_redeem_ids.cdc @@ -0,0 +1,5 @@ +import "PMStrategiesV1" + +access(all) fun main(): [UInt64] { + return PMStrategiesV1.getAllPendingRedeemIDs() +} diff --git a/cadence/tests/scripts/pm-strategies/get_pending_redeem_info.cdc b/cadence/tests/scripts/pm-strategies/get_pending_redeem_info.cdc new file mode 100644 index 00000000..c1840f69 --- /dev/null +++ b/cadence/tests/scripts/pm-strategies/get_pending_redeem_info.cdc @@ -0,0 +1,5 @@ +import "PMStrategiesV1" + +access(all) fun main(yieldVaultID: UInt64): PMStrategiesV1.PendingRedeemInfo? { + return PMStrategiesV1.getPendingRedeemInfo(yieldVaultID: yieldVaultID) +} diff --git a/cadence/tests/scripts/pm-strategies/get_pending_redeem_nav_balance.cdc b/cadence/tests/scripts/pm-strategies/get_pending_redeem_nav_balance.cdc new file mode 100644 index 00000000..50b2eb9b --- /dev/null +++ b/cadence/tests/scripts/pm-strategies/get_pending_redeem_nav_balance.cdc @@ -0,0 +1,5 @@ +import "PMStrategiesV1" + +access(all) fun main(yieldVaultID: UInt64): UFix64 { + return PMStrategiesV1.getPendingRedeemNAVBalance(yieldVaultID: yieldVaultID) +} diff --git a/cadence/tests/scripts/pm-strategies/get_scheduled_claim_timestamp.cdc b/cadence/tests/scripts/pm-strategies/get_scheduled_claim_timestamp.cdc new file mode 100644 index 00000000..813db67e --- /dev/null +++ b/cadence/tests/scripts/pm-strategies/get_scheduled_claim_timestamp.cdc @@ -0,0 +1,8 @@ +import "PMStrategiesV1" + +access(all) fun main(yieldVaultID: UInt64): UFix64? { + if let ref = PMStrategiesV1.getScheduledClaim(yieldVaultID: yieldVaultID) { + return ref.timestamp + } + return nil +} diff --git a/cadence/tests/scripts/pm-strategies/get_scheduler_buffer_seconds.cdc b/cadence/tests/scripts/pm-strategies/get_scheduler_buffer_seconds.cdc new file mode 100644 index 00000000..4a9aa8a3 --- /dev/null +++ b/cadence/tests/scripts/pm-strategies/get_scheduler_buffer_seconds.cdc @@ -0,0 +1,5 @@ +import "PMStrategiesV1" + +access(all) fun main(): UFix64? { + return PMStrategiesV1.getSchedulerBufferSeconds() +} diff --git a/cadence/tests/scripts/pm-strategies/get_yield_vault_nav_balance.cdc b/cadence/tests/scripts/pm-strategies/get_yield_vault_nav_balance.cdc new file mode 100644 index 00000000..3f7933ac --- /dev/null +++ b/cadence/tests/scripts/pm-strategies/get_yield_vault_nav_balance.cdc @@ -0,0 +1,9 @@ +import "FlowYieldVaults" + +access(all) +fun main(address: Address, id: UInt64): UFix64? { + let yieldVault = getAccount(address).capabilities.borrow<&FlowYieldVaults.YieldVaultManager>(FlowYieldVaults.YieldVaultManagerPublicPath) + ?.borrowYieldVault(id: id) + ?? nil + return yieldVault?.getNAVBalance() ?? nil +} diff --git a/cadence/tests/transactions/pm-strategies/clear_redeem_request.cdc b/cadence/tests/transactions/pm-strategies/clear_redeem_request.cdc new file mode 100644 index 00000000..b0c7d02d --- /dev/null +++ b/cadence/tests/transactions/pm-strategies/clear_redeem_request.cdc @@ -0,0 +1,22 @@ +import "EVM" +import "PMStrategiesV1" + +/// Test transaction: cancels a pending deferred redemption. +/// +/// @param yieldVaultID: The user's YieldVault ID +/// +transaction(yieldVaultID: UInt64) { + let userCOA: auth(EVM.Call) &EVM.CadenceOwnedAccount + + prepare(signer: auth(BorrowValue) &Account) { + self.userCOA = signer.storage.borrow(from: /storage/evm) + ?? panic("Could not borrow CadenceOwnedAccount reference from /storage/evm") + } + + execute { + PMStrategiesV1.clearRedeemRequest( + yieldVaultID: yieldVaultID, + userCOA: self.userCOA + ) + } +} diff --git a/cadence/tests/transactions/pm-strategies/request_redeem.cdc b/cadence/tests/transactions/pm-strategies/request_redeem.cdc new file mode 100644 index 00000000..5002127d --- /dev/null +++ b/cadence/tests/transactions/pm-strategies/request_redeem.cdc @@ -0,0 +1,43 @@ +import "FungibleToken" +import "FlowToken" +import "EVM" +import "PMStrategiesV1" + +/// Test transaction: requests a deferred redemption for a syWFLOWv yield vault. +/// Single signer — test accounts can pay their own scheduling fees. +/// +/// @param yieldVaultID: The user's YieldVault ID +/// @param amount: Underlying asset amount to redeem (nil = all) +/// @param schedulingFeeAmount: FlowToken amount for FlowTransactionScheduler fees +/// +transaction( + yieldVaultID: UInt64, + amount: UFix64?, + schedulingFeeAmount: UFix64 +) { + let userCOA: auth(EVM.Call) &EVM.CadenceOwnedAccount + let userAddress: Address + let fees: @FlowToken.Vault + + prepare(signer: auth(BorrowValue) &Account) { + self.userAddress = signer.address + + self.userCOA = signer.storage.borrow(from: /storage/evm) + ?? panic("Could not borrow CadenceOwnedAccount reference from /storage/evm") + + let flowVault = signer.storage.borrow( + from: /storage/flowTokenVault + ) ?? panic("Could not borrow FlowToken Vault reference from /storage/flowTokenVault") + self.fees <- flowVault.withdraw(amount: schedulingFeeAmount) as! @FlowToken.Vault + } + + execute { + PMStrategiesV1.requestRedeem( + yieldVaultID: yieldVaultID, + amount: amount, + userCOA: self.userCOA, + userFlowAddress: self.userAddress, + fees: <-self.fees + ) + } +} diff --git a/cadence/transactions/flow-yield-vaults/admin/init_pending_redeem_handler.cdc b/cadence/transactions/flow-yield-vaults/admin/init_pending_redeem_handler.cdc new file mode 100644 index 00000000..4e051ae7 --- /dev/null +++ b/cadence/transactions/flow-yield-vaults/admin/init_pending_redeem_handler.cdc @@ -0,0 +1,10 @@ +import "PMStrategiesV1" + +/// Initializes the PendingRedeemHandler in the PMStrategiesV1 contract account. +/// Idempotent — safe to call multiple times; no-op if handler already exists. +/// No signer required: the function writes only to the contract's own storage. +transaction() { + execute { + PMStrategiesV1.initPendingRedeemHandler() + } +}