diff --git a/cadence/contracts/PMStrategiesV1.cdc b/cadence/contracts/PMStrategiesV1.cdc index eb045d41..21eb277f 100644 --- a/cadence/contracts/PMStrategiesV1.cdc +++ b/cadence/contracts/PMStrategiesV1.cdc @@ -58,6 +58,12 @@ access(all) contract PMStrategiesV1 { userAddress: Address, vaultEVMAddressHex: String ) + access(all) event RedeemRecovered( + yieldVaultID: UInt64, + userAddress: Address, + vaultEVMAddressHex: String, + reason: String + ) access(all) let univ3FactoryEVMAddress: EVM.EVMAddress access(all) let univ3RouterEVMAddress: EVM.EVMAddress @@ -309,7 +315,8 @@ 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. + /// FUSDEVStrategy uses direct instant redemption — no deferred redeem support. access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) } @@ -650,7 +657,7 @@ access(all) contract PMStrategiesV1 { ) let navWei = ERC4626Utils.convertToAssets(vault: vaultAddr, shares: sharesWei) - ?? panic("convertToAssets failed for vault ".concat(vaultAddr.toString())) + ?? panic("convertToAssets failed for vault \(vaultAddr.toString())") let assetAddr = ERC4626Utils.underlyingAssetEVMAddress(vault: vaultAddr) ?? panic("No underlying asset EVM address found for vault \(vaultAddr.toString())") @@ -734,20 +741,23 @@ access(all) contract PMStrategiesV1 { assert(res.status == EVM.Status.successful, message: "approve failed: status \(res.status.rawValue)") } + /// Calls EVM redeem. Returns the assets received on success, or nil if the EVM call reverted. access(self) fun _evmRedeem( coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, vault: EVM.EVMAddress, shares: UInt256, receiver: EVM.EVMAddress, owner: EVM.EVMAddress - ): UInt256 { + ): 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)") + if res.status != EVM.Status.successful { + return nil + } let decoded = EVM.decodeABI(types: [Type()], data: res.data) return decoded[0] as! UInt256 } @@ -831,7 +841,7 @@ access(all) contract PMStrategiesV1 { if self.pendingRedeems[yieldVaultID] == nil { return } - PMStrategiesV1._claimRedeem(yieldVaultID: yieldVaultID) + PMStrategiesV1.claimRedeem(yieldVaultID: yieldVaultID) } access(contract) fun setPendingRedeem(id: UInt64, info: PendingRedeemInfo) { @@ -871,6 +881,33 @@ access(all) contract PMStrategiesV1 { access(contract) view fun getAllPendingRedeemIDs(): [UInt64] { return self.pendingRedeems.keys } + + access(contract) fun setClaimOutcome(yieldVaultID: UInt64, outcome: String) { + if self.metadata["claimOutcomes"] == nil { + self.metadata["claimOutcomes"] = {} as {UInt64: String} + } + let entry: auth(Mutate) &AnyStruct = &self.metadata["claimOutcomes"]! + let outcomes = entry as! auth(Mutate) &{UInt64: String} + outcomes[yieldVaultID] = outcome + } + + access(contract) fun clearClaimOutcome(yieldVaultID: UInt64) { + if self.metadata["claimOutcomes"] == nil { + return + } + let entry: auth(Remove) &AnyStruct = &self.metadata["claimOutcomes"]! + let outcomes = entry as! auth(Remove) &{UInt64: String} + outcomes.remove(key: yieldVaultID) + } + + access(contract) view fun getClaimOutcome(yieldVaultID: UInt64): String? { + if self.metadata["claimOutcomes"] == nil { + return nil + } + let entry: &AnyStruct = &self.metadata["claimOutcomes"]! + let outcomes = entry as! &{UInt64: String} + return outcomes[yieldVaultID] + } } /// Computes the storage path for the PendingRedeemHandler. @@ -929,6 +966,7 @@ access(all) contract PMStrategiesV1 { ) { pre { fees.balance > 0.0: "Scheduling fees must be provided" + amount == nil || amount! > 0.0: "Amount must be positive or nil for redeem-all" } let handler = self._borrowHandler() ?? panic("PendingRedeemHandler not initialized") @@ -1003,7 +1041,10 @@ access(all) contract PMStrategiesV1 { ?? panic("Could not borrow service COA") self._evmApprove(coa: userCOA, token: vaultEVMAddress, spender: serviceCOA.address(), amount: sharesEVM) - // 5. Record pending redeem + // 5. Clear any stale claim outcome from a previous cycle + handler.clearClaimOutcome(yieldVaultID: yieldVaultID) + + // 6. Record pending redeem handler.setPendingRedeem(id: yieldVaultID, info: PendingRedeemInfo( sharesEVM: sharesEVM, userCOAEVMAddress: userCOA.address(), @@ -1011,7 +1052,7 @@ access(all) contract PMStrategiesV1 { vaultEVMAddress: vaultEVMAddress )) - // 6. Schedule automated claim after timelock expires. + // 7. 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") @@ -1046,18 +1087,31 @@ access(all) contract PMStrategiesV1 { ) } - /// 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) { + /// Completes a pending deferred redemption. Permissionless — called automatically by + /// PendingRedeemHandler.executeTransaction when the timelock expires, or manually by + /// anyone to retry if the scheduled execution failed or was missed. + /// + /// Attempts to redeem shares via service COA. On success, converts underlying ERC-20 + /// to Cadence and deposits to user's wallet. On EVM redeem failure (e.g. vault paused), + /// automatically recovers shares back to the user's AutoBalancer so funds are never left + /// in a stuck state. + access(all) 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 scheduledClaim = handler.getScheduledClaim(id: yieldVaultID) + ?? panic("No scheduled claim for vault \(yieldVaultID)") + assert( + getCurrentBlock().timestamp >= scheduledClaim.timestamp, + message: "Timelock has not expired yet (claimable after \(scheduledClaim.timestamp))" + ) + let coa = self._getCOACapability().borrow() ?? panic("Could not borrow service COA") - // 1. Redeem: service COA calls redeem(shares, receiver=serviceCOA, owner=userCOA) + // 1. Try redeem: service COA calls redeem(shares, receiver=serviceCOA, owner=userCOA) let assetsReceived = self._evmRedeem( coa: coa, vault: info.vaultEVMAddress, @@ -1066,7 +1120,26 @@ access(all) contract PMStrategiesV1 { owner: info.userCOAEVMAddress ) - // 2. Convert underlying ERC-20 tokens from EVM to Cadence and deliver to user + // 2. If redeem failed or returned zero assets, recover shares to AutoBalancer. + // nil = EVM revert (e.g., stale oracle) — shares untouched, transferFrom recovers them. + // zero = vault burned shares but delivered nothing — transferFrom will also fail, + // causing this function to revert (atomic), preserving pending state for retry. + if assetsReceived == nil || assetsReceived! == 0 { + self._recoverSharesToAutoBalancer(yieldVaultID: yieldVaultID, info: info) + handler.setClaimOutcome(yieldVaultID: yieldVaultID, outcome: "failed") + emit RedeemRecovered( + yieldVaultID: yieldVaultID, + userAddress: info.userFlowAddress, + vaultEVMAddressHex: info.vaultEVMAddress.toString(), + reason: assetsReceived == nil ? "evm_revert" : "zero_assets" + ) + handler.removePendingRedeem(id: yieldVaultID) + handler.removeScheduledTx(id: yieldVaultID) + return + } + + // 3. Convert underlying ERC-20 tokens from EVM to Cadence and deliver to user + let assets = assetsReceived! let underlyingAddress = ERC4626Utils.underlyingAssetEVMAddress(vault: info.vaultEVMAddress) ?? panic("Could not get underlying asset address") @@ -1074,12 +1147,12 @@ access(all) contract PMStrategiesV1 { if wflowAddress != nil && underlyingAddress.bytes == wflowAddress!.bytes { let unwrapResult = coa.call( to: underlyingAddress, - data: EVM.encodeABIWithSignature("withdraw(uint256)", [assetsReceived]), + data: EVM.encodeABIWithSignature("withdraw(uint256)", [assets]), 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 flowVault <- coa.withdraw(balance: EVM.Balance(attoflow: UInt(assets))) let receiver = getAccount(info.userFlowAddress).capabilities .borrow<&{FungibleToken.Receiver}>(/public/flowTokenReceiver) ?? panic("Could not borrow user's FlowToken Receiver at \(info.userFlowAddress)") @@ -1090,7 +1163,7 @@ access(all) contract PMStrategiesV1 { let bridgeFeeProvider <- self._createBridgeFeeProvider() let tokenVault <- coa.withdrawTokens( type: underlyingCadenceType, - amount: assetsReceived, + amount: assets, feeProvider: &bridgeFeeProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} ) destroy bridgeFeeProvider @@ -1110,17 +1183,69 @@ access(all) contract PMStrategiesV1 { receiver.deposit(from: <-tokenVault) } - // 3. Cleanup + // 4. Cleanup + handler.setClaimOutcome(yieldVaultID: yieldVaultID, outcome: "success") emit RedeemClaimed( yieldVaultID: yieldVaultID, userAddress: info.userFlowAddress, - assetsReceivedEVM: assetsReceived, + assetsReceivedEVM: assets, vaultEVMAddressHex: info.vaultEVMAddress.toString() ) handler.removePendingRedeem(id: yieldVaultID) handler.removeScheduledTx(id: yieldVaultID) } + /// Recovers shares from a user's COA back to their AutoBalancer via the service COA. + /// Used by claimRedeem's automatic recovery fallback when the EVM redeem reverts. + /// The service COA pulls shares via its ERC-20 allowance (set during requestRedeem), + /// bridges them to Cadence, and deposits to AutoBalancer. + access(self) fun _recoverSharesToAutoBalancer(yieldVaultID: UInt64, info: PendingRedeemInfo) { + let serviceCOA = self._getCOACapability().borrow() + ?? panic("Could not borrow service COA") + + // Pull shares from user's COA to service COA via transferFrom (uses existing allowance) + let transferResult = serviceCOA.call( + to: info.vaultEVMAddress, + data: EVM.encodeABIWithSignature( + "transferFrom(address,address,uint256)", + [info.userCOAEVMAddress, serviceCOA.address(), info.sharesEVM] + ), + gasLimit: 15_000_000, + value: EVM.Balance(attoflow: 0) + ) + assert(transferResult.status == EVM.Status.successful, message: "Share transferFrom to service COA failed") + + self._depositSharesToAutoBalancer( + serviceCOA: serviceCOA, vaultEVMAddress: info.vaultEVMAddress, + sharesEVM: info.sharesEVM, yieldVaultID: yieldVaultID + ) + } + + /// Bridges shares held by the service COA back to Cadence and deposits them into + /// the user's AutoBalancer. Shared by _recoverSharesToAutoBalancer and clearRedeemRequest. + access(self) fun _depositSharesToAutoBalancer( + serviceCOA: auth(EVM.Call, EVM.Bridge, EVM.Owner) &EVM.CadenceOwnedAccount, + vaultEVMAddress: EVM.EVMAddress, + sharesEVM: UInt256, + yieldVaultID: UInt64 + ) { + let yieldTokenType = FlowEVMBridgeConfig.getTypeAssociated(with: vaultEVMAddress) + ?? panic("Could not resolve Cadence type for vault \(vaultEVMAddress.toString())") + let scopedProvider <- self._createBridgeFeeProvider() + let yieldTokenVault <- serviceCOA.withdrawTokens( + type: yieldTokenType, + amount: sharesEVM, + feeProvider: &scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + destroy scopedProvider + + 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 + } + /// 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. /// @@ -1170,25 +1295,14 @@ access(all) contract PMStrategiesV1 { // 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} + // 3-4. Bridge shares back to Cadence and deposit to AutoBalancer + self._depositSharesToAutoBalancer( + serviceCOA: serviceCOA, vaultEVMAddress: info.vaultEVMAddress, + sharesEVM: info.sharesEVM, yieldVaultID: yieldVaultID ) - 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 + handler.clearClaimOutcome(yieldVaultID: yieldVaultID) emit RedeemCancelled( yieldVaultID: yieldVaultID, userAddress: info.userFlowAddress, @@ -1247,6 +1361,16 @@ access(all) contract PMStrategiesV1 { return nil } + /// Returns the claim outcome for a yield vault: "success" if tokens were delivered, + /// "failed" if shares were recovered to AutoBalancer, or nil if no outcome recorded + /// (still pending, cancelled by user, or never requested). + access(all) view fun getClaimOutcome(yieldVaultID: UInt64): String? { + if let handler = self._borrowHandler() { + return handler.getClaimOutcome(yieldVaultID: yieldVaultID) + } + 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. diff --git a/cadence/tests/PMStrategiesV1_deferred_redeem_test.cdc b/cadence/tests/PMStrategiesV1_deferred_redeem_test.cdc index 0cd6c466..2341b31d 100644 --- a/cadence/tests/PMStrategiesV1_deferred_redeem_test.cdc +++ b/cadence/tests/PMStrategiesV1_deferred_redeem_test.cdc @@ -2,7 +2,7 @@ import Test -/// Fork test for PMStrategiesV1 deferred redemption — validates request/query/cancel +/// Fork test for PMStrategiesV1 deferred redemption — validates request/query/cancel/recovery /// against real mainnet EVM state (More Vaults Diamond vault with withdrawal queue). /// /// Tests: @@ -15,10 +15,11 @@ import Test /// 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. +/// 10. Negative: claimRedeem with no pending redeem +/// 11. claimRedeem before timelock — rejected by timestamp guard +/// 12. EVM revert recovery: after timelock, redeem() reverts (stale oracle), +/// _evmRedeem returns nil, shares recovered to AutoBalancer +/// 13. Re-request after recovery — verifies orphaned EVM request doesn't block a new requestRedeem /// /// Mainnet addresses: /// - Admin (FlowYieldVaults deployer): 0xb1d63873c3cc9f79 @@ -40,6 +41,7 @@ access(all) let schedulingFee = 0.5 // --- Test State --- access(all) var yieldVaultID: UInt64 = 0 +access(all) var abBalanceBeforeRedeem = 0.0 /* --- Helpers --- */ @@ -243,6 +245,13 @@ access(all) fun testRequestRedeem() { ).returnValue! as! UFix64?)! log("NAV balance before requestRedeem: \(navBefore)") + // Capture AutoBalancer share balance before redeem for end-to-end recovery verification + abBalanceBeforeRedeem = (_executeScript( + "../scripts/flow-yield-vaults/get_auto_balancer_balance_by_id.cdc", + [yieldVaultID] + ).returnValue! as! UFix64?)! + log("AutoBalancer share balance before requestRedeem: \(abBalanceBeforeRedeem)") + log("Requesting deferred redeem for 1.0 FLOW worth of shares...") let result = _executeTransactionFile( "transactions/pm-strategies/request_redeem.cdc", @@ -303,15 +312,15 @@ access(all) fun testDuplicateRequestRedeemFails() { } access(all) fun testViewFunctionsWhilePending() { - // getAllPendingRedeemIDs — should contain exactly our yieldVaultID + // getAllPendingRedeemIDs — should contain 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.contains(yieldVaultID), message: "Expected pending redeem IDs to contain yieldVaultID") - log("getAllPendingRedeemIDs contains: \(yieldVaultID)") + Test.assert(ids.contains(yieldVaultID), message: "Pending IDs should contain our yieldVaultID") + log("getAllPendingRedeemIDs contains \(yieldVaultID)") // getScheduledClaim — should return a future timestamp let tsResult = _executeScript( @@ -397,7 +406,7 @@ access(all) fun testClearRedeemRequest() { ) Test.expect(idsResult, Test.beSucceeded()) let clearedIds = idsResult.returnValue! as! [UInt64] - Test.assert(!clearedIds.contains(yieldVaultID), message: "Expected yieldVaultID to be absent from pending redeem IDs after clear") + Test.assert(!clearedIds.contains(yieldVaultID), message: "Pending IDs should not contain our yieldVaultID after clear") let tsResult = _executeScript( "scripts/pm-strategies/get_scheduled_claim_timestamp.cdc", @@ -474,6 +483,160 @@ access(all) fun testRedeemAllAfterCancel() { ) Test.expect(idsResult, Test.beSucceeded()) let clearedIds = idsResult.returnValue! as! [UInt64] - Test.assert(!clearedIds.contains(yieldVaultID), message: "Expected yieldVaultID to be absent from pending redeems after re-cancel") - log("Re-request → cancel lifecycle complete") + Test.assert(!clearedIds.contains(yieldVaultID), message: "Pending IDs should not contain our yieldVaultID after re-cancel") + log("Re-request -> cancel lifecycle complete") +} + +access(all) fun testClaimRedeemNoPendingFails() { + log("Attempting claimRedeem with no pending redeem (should fail)...") + let result = _executeTransactionFile( + "transactions/pm-strategies/claim_redeem.cdc", + [yieldVaultID], + [] + ) + Test.expect(result, Test.beFailed()) + log("claimRedeem with no pending correctly rejected") +} + +access(all) fun testClaimRedeemBeforeTimelockFails() { + let navBefore = (_executeScript( + "scripts/pm-strategies/get_yield_vault_nav_balance.cdc", + [userAccount.address, yieldVaultID] + ).returnValue! as! UFix64?)! + log("NAV before timelock-guard test: \(navBefore)") + + // Request a deferred redeem + log("Requesting deferred redeem for 1.0 FLOW worth of shares...") + var result = _executeTransactionFile( + "transactions/pm-strategies/request_redeem.cdc", + [yieldVaultID, 1.0 as UFix64?, schedulingFee], + [userAccount] + ) + Test.expect(result, Test.beSucceeded()) + log("requestRedeem succeeded") + + // Call claimRedeem immediately — should be rejected by timestamp guard + log("Calling claimRedeem before timelock (should be rejected)...") + result = _executeTransactionFile( + "transactions/pm-strategies/claim_redeem.cdc", + [yieldVaultID], + [] + ) + Test.expect(result, Test.beFailed()) + log("claimRedeem before timelock correctly rejected") + + // Pending state should still exist (nothing changed) + let infoResult = _executeScript( + "scripts/pm-strategies/get_pending_redeem_info.cdc", + [yieldVaultID] + ) + Test.expect(infoResult, Test.beSucceeded()) + Test.assert(infoResult.returnValue != nil, message: "Pending redeem should still exist after rejected claim") + log("Pending state preserved after rejected early claim") +} + +access(all) fun testEVMRedeemRevertTriggersRecovery() { + // Pending redeem exists from testClaimRedeemBeforeTimelockFails. + let infoBefore = _executeScript( + "scripts/pm-strategies/get_pending_redeem_info.cdc", + [yieldVaultID] + ) + Test.expect(infoBefore, Test.beSucceeded()) + Test.assert(infoBefore.returnValue != nil, message: "Expected pending redeem before moveTime") + + let abBefore = (_executeScript( + "../scripts/flow-yield-vaults/get_auto_balancer_balance_by_id.cdc", + [yieldVaultID] + ).returnValue! as! UFix64?)! + let userFlowBefore = getAccount(userAccount.address).balance + + // moveTime advances past the scheduled timestamp (timelock + schedulerBuffer). + // The 48h time jump makes EVM oracle data stale → redeem() reverts → + // _evmRedeem returns nil → recovery pulls shares back to AutoBalancer. + Test.moveTime(by: 172831.0) + + // Pending state cleared + let infoAfter = _executeScript( + "scripts/pm-strategies/get_pending_redeem_info.cdc", + [yieldVaultID] + ) + Test.expect(infoAfter, Test.beSucceeded()) + Test.assert(infoAfter.returnValue == nil, message: "Expected pending redeem cleared after recovery") + + 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 == 0, message: "Expected no pending redeem IDs after recovery") + + // Shares recovered to AutoBalancer + let abAfter = (_executeScript( + "../scripts/flow-yield-vaults/get_auto_balancer_balance_by_id.cdc", + [yieldVaultID] + ).returnValue! as! UFix64?)! + Test.assert(abAfter > abBefore, message: "Expected AutoBalancer increase from recovered shares") + log("Shares recovered to AutoBalancer (delta: \(abAfter - abBefore))") + + // User received no FLOW (recovery, not happy path) + let userFlowAfter = getAccount(userAccount.address).balance + Test.assert( + userFlowAfter == userFlowBefore, + message: "Expected no FLOW change for user during recovery" + ) + + // Claim outcome should be "failed" (recovery, not success) + let outcomeResult = _executeScript( + "scripts/pm-strategies/get_claim_outcome.cdc", + [yieldVaultID] + ) + Test.expect(outcomeResult, Test.beSucceeded()) + let outcome = outcomeResult.returnValue! as! String? + Test.assert(outcome == "failed", message: "Expected claim outcome 'failed' after recovery") + log("Claim outcome after recovery: \(outcome!)") +} + +access(all) fun testRequestRedeemAfterRecovery() { + // After recovery, Cadence state is clean but the EVM vault may still have + // an orphaned withdrawal request from the previous requestRedeem. + // This test verifies a new requestRedeem succeeds despite that. + // + // NOTE: EVM view calls (navBalance, convertToAssets) are unreliable after + // the 48h moveTime — oracle data is stale. Use Cadence-side AutoBalancer + // balance and nil-amount redeem (skips convertToShares EVM call). + + let abBalance = (_executeScript( + "../scripts/flow-yield-vaults/get_auto_balancer_balance_by_id.cdc", + [yieldVaultID] + ).returnValue! as! UFix64?)! + log("AutoBalancer share balance after recovery: \(abBalance)") + Test.assert(abBalance > 0.0, message: "Expected positive AutoBalancer balance from recovered shares") + + // Use nil amount to redeem all shares — avoids convertToShares EVM call + log("Requesting deferred redeem (all) after recovery...") + let result = _executeTransactionFile( + "transactions/pm-strategies/request_redeem.cdc", + [yieldVaultID, nil as UFix64?, schedulingFee], + [userAccount] + ) + Test.expect(result, Test.beSucceeded()) + log("requestRedeem after recovery succeeded — orphaned EVM request did not block") + + // 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 after re-request") + + // Claim outcome should be cleared on new requestRedeem + let outcomeResult = _executeScript( + "scripts/pm-strategies/get_claim_outcome.cdc", + [yieldVaultID] + ) + Test.expect(outcomeResult, Test.beSucceeded()) + Test.assert(outcomeResult.returnValue == nil, message: "Expected nil claim outcome after re-request") + log("Re-request after recovery lifecycle complete") } diff --git a/cadence/tests/scripts/pm-strategies/get_claim_outcome.cdc b/cadence/tests/scripts/pm-strategies/get_claim_outcome.cdc new file mode 100644 index 00000000..21bebbb6 --- /dev/null +++ b/cadence/tests/scripts/pm-strategies/get_claim_outcome.cdc @@ -0,0 +1,5 @@ +import "PMStrategiesV1" + +access(all) fun main(yieldVaultID: UInt64): String? { + return PMStrategiesV1.getClaimOutcome(yieldVaultID: yieldVaultID) +} diff --git a/cadence/tests/transactions/pm-strategies/claim_redeem.cdc b/cadence/tests/transactions/pm-strategies/claim_redeem.cdc new file mode 100644 index 00000000..259ceba6 --- /dev/null +++ b/cadence/tests/transactions/pm-strategies/claim_redeem.cdc @@ -0,0 +1,12 @@ +import "PMStrategiesV1" + +/// Test transaction: calls the permissionless claimRedeem to complete or recover +/// a pending deferred redemption. +/// +/// @param yieldVaultID: The yield vault ID with a pending redeem +/// +transaction(yieldVaultID: UInt64) { + execute { + PMStrategiesV1.claimRedeem(yieldVaultID: yieldVaultID) + } +}