Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 157 additions & 33 deletions cadence/contracts/PMStrategiesV1.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()!)
}
Expand Down Expand Up @@ -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())")
Expand Down Expand Up @@ -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<UInt256>()], data: res.data)
return decoded[0] as! UInt256
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1003,15 +1041,18 @@ 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(),
userFlowAddress: userFlowAddress,
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")
Expand Down Expand Up @@ -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,
Expand All @@ -1066,20 +1120,39 @@ 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")

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]),
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)")
Expand All @@ -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
Expand All @@ -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.
///
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading