From fdc137d28260c46ef261dcfbe25912663dac3d88 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:51:42 -0500 Subject: [PATCH 01/56] relax dust balance --- cadence/contracts/FlowALPv1.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/contracts/FlowALPv1.cdc b/cadence/contracts/FlowALPv1.cdc index ec6ed8b2..16e7d9c7 100644 --- a/cadence/contracts/FlowALPv1.cdc +++ b/cadence/contracts/FlowALPv1.cdc @@ -3011,7 +3011,7 @@ access(all) contract FlowALPv1 { // This is applied to both credit and debit balances, with the main goal being to avoid dust positions. assert( - remainingBalance == 0.0 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), + remainingBalance <= 0.00001000 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), message: "Withdrawal would leave position below minimum balance requirement of \(self.globalLedger[type]!.minimumTokenBalancePerPosition). Remaining balance would be \(remainingBalance)." ) From ebe265a2131bc565e9ddde254c3b03b89c878772 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:46:24 -0500 Subject: [PATCH 02/56] restore relax --- cadence/contracts/FlowALPv1.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/contracts/FlowALPv1.cdc b/cadence/contracts/FlowALPv1.cdc index ec6ed8b2..508e6f21 100644 --- a/cadence/contracts/FlowALPv1.cdc +++ b/cadence/contracts/FlowALPv1.cdc @@ -3011,7 +3011,7 @@ access(all) contract FlowALPv1 { // This is applied to both credit and debit balances, with the main goal being to avoid dust positions. assert( - remainingBalance == 0.0 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), + remainingBalance 0.00000300 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), message: "Withdrawal would leave position below minimum balance requirement of \(self.globalLedger[type]!.minimumTokenBalancePerPosition). Remaining balance would be \(remainingBalance)." ) From 8fd49d3f3a2647d8ae143c0dcff5c72fa190da1a Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:59:03 -0500 Subject: [PATCH 03/56] fix typo --- cadence/contracts/FlowALPv1.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/contracts/FlowALPv1.cdc b/cadence/contracts/FlowALPv1.cdc index 508e6f21..5c3b98a2 100644 --- a/cadence/contracts/FlowALPv1.cdc +++ b/cadence/contracts/FlowALPv1.cdc @@ -3011,7 +3011,7 @@ access(all) contract FlowALPv1 { // This is applied to both credit and debit balances, with the main goal being to avoid dust positions. assert( - remainingBalance 0.00000300 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), + remainingBalance < 0.00000300 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), message: "Withdrawal would leave position below minimum balance requirement of \(self.globalLedger[type]!.minimumTokenBalancePerPosition). Remaining balance would be \(remainingBalance)." ) From 760b53d01d968180c404b9cdae0f290af8e42676 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:00:02 -0500 Subject: [PATCH 04/56] update ref --- FlowActions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlowActions b/FlowActions index 527b2e5b..03fc42e4 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit 527b2e5b5aac4093ee3dc71ab47ff62bf3283733 +Subproject commit 03fc42e4bb076f6badf1a2412a678dcbcf5a03d1 From 015a9d8d02da71cf757c717072c9672919473897 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:52:46 -0500 Subject: [PATCH 05/56] ref bridge exact --- FlowActions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlowActions b/FlowActions index 03fc42e4..c21e486c 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit 03fc42e4bb076f6badf1a2412a678dcbcf5a03d1 +Subproject commit c21e486cc0425a2c623d4fa113e5e2cef92cd937 From 972ac4c3e07f7074089e22751761d4c040c026c2 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:10:49 -0500 Subject: [PATCH 06/56] update ref --- FlowActions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlowActions b/FlowActions index c21e486c..bdf4f063 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit c21e486cc0425a2c623d4fa113e5e2cef92cd937 +Subproject commit bdf4f063dd9db8fab64612cb08df1e522639e9ed From d9970e3d7aedffcb15eb1f953b299173c137f718 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:00:56 -0500 Subject: [PATCH 07/56] update ref --- FlowActions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlowActions b/FlowActions index bdf4f063..2357ae77 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit bdf4f063dd9db8fab64612cb08df1e522639e9ed +Subproject commit 2357ae770e6c5cccae65e2965f75b4fba0a64ed9 From f158d124320c8d5f946c16a4846e4f77f9f19de0 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:54:44 -0500 Subject: [PATCH 08/56] close position method --- cadence/contracts/FlowALPv0.cdc | 186 +++++++++++++++++++++++++++++++- 1 file changed, 185 insertions(+), 1 deletion(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 72098ab6..dd78c9ba 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -57,6 +57,18 @@ access(all) contract FlowALPv0 { withdrawnUUID: UInt64 ) + /// Emitted when a position is closed via the closePosition() method. + /// This indicates a full position closure with debt repayment and collateral extraction. + access(all) event PositionClosed( + pid: UInt64, + poolUUID: UInt64, + repaymentAmount: UFix64, + repaymentType: Type, + collateralAmount: UFix64, + collateralType: Type, + finalDebt: UFix128 + ) + access(all) event Rebalanced( pid: UInt64, poolUUID: UInt64, @@ -3013,7 +3025,7 @@ access(all) contract FlowALPv0 { // This is applied to both credit and debit balances, with the main goal being to avoid dust positions. assert( - remainingBalance < 0.00000300 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), + remainingBalance = 0.0 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), message: "Withdrawal would leave position below minimum balance requirement of \(self.globalLedger[type]!.minimumTokenBalancePerPosition). Remaining balance would be \(remainingBalance)." ) @@ -3034,6 +3046,157 @@ access(all) contract FlowALPv0 { return <- withdrawn } + /// Closes a position by repaying all debt and returning all residual collateral. + /// This is the recommended way to close a leveraged position that may have dust residuals. + /// + /// This method follows the same pattern as withdrawAndPull(): + /// - Pulls ONLY the exact amount needed from the repayment source + /// - Returns ALL collateral (including dust) as a vault + /// + /// Steps: + /// 1. Calculates total debt + /// 2. Pulls exact repayment amount from source + /// 3. Deposits repayment to eliminate debt + /// 4. Verifies debt is fully repaid (near-zero) + /// 5. Withdraws ALL remaining collateral (including dust) + /// 6. Returns collateral vault + /// + /// @param pid: Position ID to close + /// @param repaymentSource: Source to pull debt repayment from (e.g., AutoBalancer with YT) + /// @param collateralType: Type of collateral to extract and return (e.g., FlowToken) + /// @return Vault containing all collateral including dust + access(EPosition) fun closePosition( + pid: UInt64, + repaymentSource: auth(FungibleToken.Withdraw) &{DeFiActions.Source}, + collateralType: Type + ): @{FungibleToken.Vault} { + pre { + !self.isPausedOrWarmup(): "Operations are paused by governance" + self.positions[pid] != nil: "Invalid position ID" + } + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } + + self._lockPosition(pid) + + if self.debugLogging { + log(" [CONTRACT] closePosition(pid: \(pid), collateralType: \(collateralType.identifier))") + } + + // Step 1: Calculate total debt that needs to be repaid + let positionDetails = self.getPositionDetails(pid: pid) + var totalDebtAmount: UFix64 = 0.0 + var debtType: Type? = nil + + for balance in positionDetails.balances { + if balance.direction == BalanceDirection.Debit { + // Accumulate debt (assuming single debt type for now) + totalDebtAmount = totalDebtAmount + UFix64(balance.balance) + debtType = balance.vaultType + } + } + + // Verify we have debt to repay (or allow closing if already no debt) + if totalDebtAmount == 0.0 { + // No debt - just withdraw all collateral + let collateralBalance = self.buildPositionView(pid: pid).trueBalance(ofToken: collateralType) + let withdrawn <- self.withdrawAndPull( + pid: pid, + type: collateralType, + amount: UFix64(collateralBalance), + pullFromTopUpSource: false + ) + + emit PositionClosed( + pid: pid, + poolUUID: self.uuid, + repaymentAmount: 0.0, + repaymentType: collateralType, + collateralAmount: withdrawn.balance, + collateralType: collateralType, + finalDebt: 0.0 + ) + + self._unlockPosition(pid) + return <-withdrawn + } + + // Step 3: Pull EXACT amount needed from repayment source + let repaymentVault <- repaymentSource.withdrawAvailable(maxAmount: totalDebtAmount) + let actualRepayment = repaymentVault.balance + let repaymentType = repaymentVault.getType() + + // Verify source provided sufficient funds + assert( + actualRepayment >= totalDebtAmount * 0.9999, // Allow 0.01% slippage for rounding + message: "Repayment source provided insufficient funds: \(actualRepayment) < \(totalDebtAmount)" + ) + + // Step 4: Deposit repayment funds to eliminate debt + self._depositEffectsOnly(pid: pid, from: <-repaymentVault) + + // Step 5: Verify debt is fully repaid + let updatedDetails = self.getPositionDetails(pid: pid) + var totalEffectiveDebt: UFix128 = 0.0 + + for balance in updatedDetails.balances { + if balance.direction == BalanceDirection.Debit { + let tokenState = self._borrowUpdatedTokenState(type: balance.vaultType) + totalEffectiveDebt = totalEffectiveDebt + tokenState.effectiveDebt(debitBalance: balance.balance) + } + } + + // Require debt to be near-zero (allow tiny precision errors) + assert( + totalEffectiveDebt < UFix128(0.00001), + message: "Cannot close position - outstanding debt remains: \(totalEffectiveDebt) USD. ".concat( + "Repayment of \(actualRepayment) was insufficient.") + ) + + // Step 6: Calculate total collateral balance + var collateralBalance: UFix128 = 0.0 + + for balance in updatedDetails.balances { + if balance.vaultType == collateralType && balance.direction == BalanceDirection.Credit { + collateralBalance = balance.balance + break + } + } + + assert( + collateralBalance > 0.0, + message: "No collateral of type \(collateralType.identifier) found in position" + ) + + // Step 7: Withdraw ALL collateral (including dust via withdrawAndPull's dust sweeping) + // Note: Position is already locked, so we use withdrawAndPull which will try to lock again + // We need to unlock first, then let withdrawAndPull lock it + self._unlockPosition(pid) + + let collateral <- self.withdrawAndPull( + pid: pid, + type: collateralType, + amount: UFix64(collateralBalance), + pullFromTopUpSource: false + ) + + let finalCollateralAmount = collateral.balance + + // Emit event for position closure + emit PositionClosed( + pid: pid, + poolUUID: self.uuid, + repaymentAmount: actualRepayment, + repaymentType: repaymentType, + collateralAmount: finalCollateralAmount, + collateralType: collateralType, + finalDebt: totalEffectiveDebt + ) + + return <-collateral + } + /////////////////////// // POOL MANAGEMENT /////////////////////// @@ -3965,6 +4128,27 @@ access(all) contract FlowALPv0 { ) } + /// Closes the position by repaying all debt and returning all residual collateral. + /// This is the recommended way to close a leveraged position that may have dust residuals. + /// + /// This is a convenience wrapper that delegates to Pool.closePosition(). + /// See Pool.closePosition() for detailed documentation. + /// + /// @param repaymentSource: Source to pull debt repayment from (e.g., AutoBalancer with YT) + /// @param collateralType: Type of collateral to extract and return (e.g., FlowToken) + /// @return Vault containing all collateral including dust + access(FungibleToken.Withdraw) fun closePosition( + repaymentSource: auth(FungibleToken.Withdraw) &{DeFiActions.Source}, + collateralType: Type + ): @{FungibleToken.Vault} { + let pool = self.pool.borrow()! + return <- pool.closePosition( + pid: self.id, + repaymentSource: repaymentSource, + collateralType: collateralType + ) + } + /// Returns a new Sink for the given token type that will accept deposits of that token /// and update the position's collateral and/or debt accordingly. /// From 2a2e5526e48065742f476b199b869e591211f23a Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:12:43 -0500 Subject: [PATCH 09/56] fix assertion --- cadence/contracts/FlowALPv0.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index dd78c9ba..2f991b06 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3025,7 +3025,7 @@ access(all) contract FlowALPv0 { // This is applied to both credit and debit balances, with the main goal being to avoid dust positions. assert( - remainingBalance = 0.0 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), + remainingBalance == 0.0 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), message: "Withdrawal would leave position below minimum balance requirement of \(self.globalLedger[type]!.minimumTokenBalancePerPosition). Remaining balance would be \(remainingBalance)." ) From 8e0f6f7d8483e5b541e4a03cce3885606114e335 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:59:46 -0500 Subject: [PATCH 10/56] close position --- cadence/contracts/FlowALPv0.cdc | 211 ++++++++++++++++++++++---------- 1 file changed, 143 insertions(+), 68 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 2f991b06..038635e9 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3008,14 +3008,24 @@ access(all) contract FlowALPv0 { amount: uintAmount, tokenState: tokenState ) + + // Check if we're withdrawing a debt token (debit balance) + let isDebtToken = position.balances[type]!.direction == BalanceDirection.Debit + // Attempt to pull additional collateral from the top-up source (if configured) // to keep the position above minHealth after the withdrawal. - // Regardless of whether a top-up occurs, the position must be healthy post-withdrawal. - let postHealth = self.positionHealth(pid: pid) - assert( - postHealth >= 1.0, - message: "Post-withdrawal position health (\(postHealth)) is unhealthy" - ) + // + // IMPORTANT: Skip health check if withdrawing debt token with pullFromTopUpSource=true + // In this case, we're getting funds for external use (e.g., closePosition repayment), + // and temporarily increasing debt is expected as part of the close flow. + // The debt will be immediately repaid by the calling context (closePosition). + if !isDebtToken || !pullFromTopUpSource { + let postHealth = self.positionHealth(pid: pid) + assert( + postHealth >= 1.0, + message: "Post-withdrawal position health (\(postHealth)) is unhealthy" + ) + } // Ensure that the remaining balance meets the minimum requirement (or is zero) // Building the position view does require copying the balances, so it's less efficient than accessing the balance directly. @@ -3046,28 +3056,29 @@ access(all) contract FlowALPv0 { return <- withdrawn } - /// Closes a position by repaying all debt and returning all residual collateral. - /// This is the recommended way to close a leveraged position that may have dust residuals. + /// Closes a position using the position's configured topUpSource for debt repayment. + /// This is a convenience method that accesses the topUpSource directly. + /// Closes a position by repaying all debt with a pre-prepared vault and returning all collateral. /// - /// This method follows the same pattern as withdrawAndPull(): - /// - Pulls ONLY the exact amount needed from the repayment source - /// - Returns ALL collateral (including dust) as a vault + /// This is the ONLY close method - users must prepare repayment funds externally. + /// This design eliminates circular dependencies and gives users full control over fund sourcing. /// /// Steps: - /// 1. Calculates total debt - /// 2. Pulls exact repayment amount from source - /// 3. Deposits repayment to eliminate debt + /// 1. Calculates total debt (read-only, no lock) + /// 2. Locks the position + /// 3. Deposits repayment vault to eliminate debt /// 4. Verifies debt is fully repaid (near-zero) /// 5. Withdraws ALL remaining collateral (including dust) /// 6. Returns collateral vault /// /// @param pid: Position ID to close - /// @param repaymentSource: Source to pull debt repayment from (e.g., AutoBalancer with YT) + /// @param repaymentVault: Vault containing funds to repay all debt (pass empty vault if no debt) /// @param collateralType: Type of collateral to extract and return (e.g., FlowToken) /// @return Vault containing all collateral including dust + /// access(EPosition) fun closePosition( pid: UInt64, - repaymentSource: auth(FungibleToken.Withdraw) &{DeFiActions.Source}, + repaymentVault: @{FungibleToken.Vault}, collateralType: Type ): @{FungibleToken.Vault} { pre { @@ -3078,13 +3089,11 @@ access(all) contract FlowALPv0 { self.positionLock[pid] == nil: "Position is not unlocked" } - self._lockPosition(pid) - if self.debugLogging { log(" [CONTRACT] closePosition(pid: \(pid), collateralType: \(collateralType.identifier))") } - // Step 1: Calculate total debt that needs to be repaid + // Step 1: Calculate total debt that needs to be repaid (NO LOCK NEEDED - read-only) let positionDetails = self.getPositionDetails(pid: pid) var totalDebtAmount: UFix64 = 0.0 var debtType: Type? = nil @@ -3097,9 +3106,17 @@ access(all) contract FlowALPv0 { } } - // Verify we have debt to repay (or allow closing if already no debt) + let actualRepayment = repaymentVault.balance + let repaymentType = repaymentVault.getType() + + // Step 2: Lock the position for all state modifications + self._lockPosition(pid) + + // Handle no-debt case if totalDebtAmount == 0.0 { - // No debt - just withdraw all collateral + // No debt - destroy repayment vault and just withdraw all collateral + destroy repaymentVault + let collateralBalance = self.buildPositionView(pid: pid).trueBalance(ofToken: collateralType) let withdrawn <- self.withdrawAndPull( pid: pid, @@ -3122,66 +3139,120 @@ access(all) contract FlowALPv0 { return <-withdrawn } - // Step 3: Pull EXACT amount needed from repayment source - let repaymentVault <- repaymentSource.withdrawAvailable(maxAmount: totalDebtAmount) - let actualRepayment = repaymentVault.balance - let repaymentType = repaymentVault.getType() + // Step 3: Accept repayment vault (allow overshoot - extra funds help ensure full repayment) + // Users can provide more than needed to handle rounding/slippage/circular dependencies + // Note: We don't enforce minimum here - we'll check final debt after deposit instead - // Verify source provided sufficient funds - assert( - actualRepayment >= totalDebtAmount * 0.9999, // Allow 0.01% slippage for rounding - message: "Repayment source provided insufficient funds: \(actualRepayment) < \(totalDebtAmount)" - ) - - // Step 4: Deposit repayment funds to eliminate debt + // Step 4: Deposit repayment funds to eliminate debt (under lock) self._depositEffectsOnly(pid: pid, from: <-repaymentVault) - // Step 5: Verify debt is fully repaid + // Step 5: Verify debt is acceptably low (allow tolerance for overshoot scenarios) let updatedDetails = self.getPositionDetails(pid: pid) var totalEffectiveDebt: UFix128 = 0.0 for balance in updatedDetails.balances { if balance.direction == BalanceDirection.Debit { - let tokenState = self._borrowUpdatedTokenState(type: balance.vaultType) - totalEffectiveDebt = totalEffectiveDebt + tokenState.effectiveDebt(debitBalance: balance.balance) + // Calculate effective debt: (debit * price) / borrowFactor + let price = self.priceOracle.price(ofToken: balance.vaultType) + ?? panic("Price not available for token \(balance.vaultType.identifier)") + let borrowFactor = self.borrowFactor[balance.vaultType] + ?? panic("Borrow factor not found for token \(balance.vaultType.identifier)") + + let effectiveDebt = FlowALPv0.effectiveDebt( + debit: UFix128(balance.balance), + price: UFix128(price), + borrowFactor: UFix128(borrowFactor) + ) + totalEffectiveDebt = totalEffectiveDebt + effectiveDebt } } - // Require debt to be near-zero (allow tiny precision errors) + // Step 6: Calculate how much collateral to return + // If there's remaining debt (e.g., from circular dependency), leave enough collateral to cover it + let positionView = self.buildPositionView(pid: pid) + let collateralBalance = positionView.trueBalance(ofToken: collateralType) + + // Calculate collateral value needed to cover remaining debt + let collateralPrice = self.priceOracle.price(ofToken: collateralType) + ?? panic("Price not available for collateral \(collateralType.identifier)") + let collateralFactor = self.collateralFactor[collateralType] + ?? panic("Collateral factor not found for \(collateralType.identifier)") + + // Remaining debt in USD / (collateral price * collateral factor) = collateral needed + let collateralNeededForDebt = UFix64(totalEffectiveDebt / (UFix128(collateralPrice) * UFix128(collateralFactor))) + + // Total available collateral in position + let totalCollateralAvailable = UFix64(collateralBalance) + + // If remaining debt requires more collateral than available, that's an error assert( - totalEffectiveDebt < UFix128(0.00001), - message: "Cannot close position - outstanding debt remains: \(totalEffectiveDebt) USD. ".concat( - "Repayment of \(actualRepayment) was insufficient.") + collateralNeededForDebt <= totalCollateralAvailable, + message: "Insufficient collateral to cover remaining debt. Debt requires \(collateralNeededForDebt) collateral but only \(totalCollateralAvailable) available. ".concat( + "Remaining debt: \(totalEffectiveDebt) USD. Please provide additional repayment funds.") ) - // Step 6: Calculate total collateral balance - var collateralBalance: UFix128 = 0.0 + // Collateral to return = total collateral - collateral covering remaining debt + let collateralToReturn = totalCollateralAvailable - collateralNeededForDebt - for balance in updatedDetails.balances { - if balance.vaultType == collateralType && balance.direction == BalanceDirection.Credit { - collateralBalance = balance.balance - break - } - } + // If there's no remaining debt, return all collateral + // If there is remaining debt, return reduced collateral (leaving debt coverage in position) + let withdrawableCollateral = totalEffectiveDebt > 0.0 + ? collateralToReturn + : totalCollateralAvailable assert( - collateralBalance > 0.0, - message: "No collateral of type \(collateralType.identifier) found in position" + withdrawableCollateral > 0.0, + message: "No collateral available to return. All collateral needed to cover remaining debt: \(totalEffectiveDebt) USD" ) - // Step 7: Withdraw ALL collateral (including dust via withdrawAndPull's dust sweeping) - // Note: Position is already locked, so we use withdrawAndPull which will try to lock again - // We need to unlock first, then let withdrawAndPull lock it + // Step 7: Withdraw collateral while maintaining position health + // If there's remaining debt, we need to leave enough collateral to keep position healthy + + // Unlock before withdrawal (withdrawAndPull will lock again) self._unlockPosition(pid) - let collateral <- self.withdrawAndPull( - pid: pid, - type: collateralType, - amount: UFix64(collateralBalance), - pullFromTopUpSource: false - ) + // Determine withdrawal amount based on remaining debt + var collateral: @{FungibleToken.Vault}? <- nil - let finalCollateralAmount = collateral.balance + if totalEffectiveDebt == 0.0 { + // No remaining debt - withdraw all collateral + let fullBalance = UFix64(positionView.trueBalance(ofToken: collateralType)) + collateral <-! self.withdrawAndPull( + pid: pid, + type: collateralType, + amount: fullBalance, + pullFromTopUpSource: false + ) + } else { + // Remaining debt exists - calculate safe withdrawal maintaining target health + let position = self._borrowPosition(pid: pid) + let targetHealth = position.targetHealth + + // Calculate collateral needed to maintain target health: + // (collateralValue * collateralFactor) / (debtValue / borrowFactor) >= targetHealth + // collateralValue >= (targetHealth * debtValue) / (collateralFactor * borrowFactor) + let borrowFactor = self.borrowFactor[debtType ?? repaymentType] ?? 1.0 + + let minCollateralValue = UFix64(targetHealth) * UFix64(totalEffectiveDebt) / (collateralFactor * borrowFactor) + let minCollateralAmount = minCollateralValue / collateralPrice + + // Get total collateral + let totalCollateral = UFix64(positionView.trueBalance(ofToken: collateralType)) + + // Withdraw total minus minimum (with small buffer for safety) + let safeWithdrawAmount = totalCollateral > minCollateralAmount + 1.0 + ? totalCollateral - minCollateralAmount - 1.0 + : 0.0 + + if safeWithdrawAmount > 0.0 { + collateral <-! self.withdrawAndPull(pid: pid, type: collateralType, amount: safeWithdrawAmount, pullFromTopUpSource: false) + } else { + collateral <-! DeFiActionsUtils.getEmptyVault(collateralType) + } + } + + let finalCollateral <- collateral! + let finalCollateralAmount = finalCollateral.balance // Emit event for position closure emit PositionClosed( @@ -3194,7 +3265,7 @@ access(all) contract FlowALPv0 { finalDebt: totalEffectiveDebt ) - return <-collateral + return <-finalCollateral } /////////////////////// @@ -4128,23 +4199,25 @@ access(all) contract FlowALPv0 { ) } - /// Closes the position by repaying all debt and returning all residual collateral. - /// This is the recommended way to close a leveraged position that may have dust residuals. + /// Closes the position by repaying all debt with a pre-prepared vault and returning all collateral. + /// + /// This is the ONLY close method. Users must prepare repayment funds externally. + /// This design eliminates circular dependencies and gives users full control over fund sourcing. /// - /// This is a convenience wrapper that delegates to Pool.closePosition(). - /// See Pool.closePosition() for detailed documentation. + /// See Pool.closePosition() for detailed implementation documentation. /// - /// @param repaymentSource: Source to pull debt repayment from (e.g., AutoBalancer with YT) + /// @param repaymentVault: Vault containing funds to repay all debt (pass empty vault if no debt) /// @param collateralType: Type of collateral to extract and return (e.g., FlowToken) /// @return Vault containing all collateral including dust + /// access(FungibleToken.Withdraw) fun closePosition( - repaymentSource: auth(FungibleToken.Withdraw) &{DeFiActions.Source}, + repaymentVault: @{FungibleToken.Vault}, collateralType: Type ): @{FungibleToken.Vault} { let pool = self.pool.borrow()! return <- pool.closePosition( pid: self.id, - repaymentSource: repaymentSource, + repaymentVault: <-repaymentVault, collateralType: collateralType ) } @@ -4394,7 +4467,9 @@ access(all) contract FlowALPv0 { /// A DeFiActions connector enabling withdrawals from a Position from within a DeFiActions stack. /// This Source is intended to be constructed from a Position object. /// - access(all) struct PositionSource: DeFiActions.Source { + /// A wrapper struct that holds a reference to a Source + /// This allows passing references as Source values to closePosition() +access(all) struct PositionSource: DeFiActions.Source { /// An optional DeFiActions.UniqueIdentifier that identifies this Sink with the DeFiActions stack its a part of access(contract) var uniqueID: DeFiActions.UniqueIdentifier? From 824c3880ee74b5b54430f8436fff1e240ebf1a6f Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:05:05 -0500 Subject: [PATCH 11/56] Apply suggestion from @nialexsan --- cadence/contracts/FlowALPv0.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 038635e9..b6b79194 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -4469,7 +4469,7 @@ access(all) contract FlowALPv0 { /// /// A wrapper struct that holds a reference to a Source /// This allows passing references as Source values to closePosition() -access(all) struct PositionSource: DeFiActions.Source { + access(all) struct PositionSource: DeFiActions.Source { /// An optional DeFiActions.UniqueIdentifier that identifies this Sink with the DeFiActions stack its a part of access(contract) var uniqueID: DeFiActions.UniqueIdentifier? From 47a9e6714f4f129516bef9feedcaed59e8324c12 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:05:24 -0500 Subject: [PATCH 12/56] Apply suggestion from @nialexsan --- cadence/contracts/FlowALPv0.cdc | 2 -- 1 file changed, 2 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index b6b79194..3ce515ec 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -4467,8 +4467,6 @@ access(all) contract FlowALPv0 { /// A DeFiActions connector enabling withdrawals from a Position from within a DeFiActions stack. /// This Source is intended to be constructed from a Position object. /// - /// A wrapper struct that holds a reference to a Source - /// This allows passing references as Source values to closePosition() access(all) struct PositionSource: DeFiActions.Source { /// An optional DeFiActions.UniqueIdentifier that identifies this Sink with the DeFiActions stack its a part of From 94df1fc2d67872593ef0dbe87e21980f01ece334 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:32:22 -0500 Subject: [PATCH 13/56] fix lock --- cadence/contracts/FlowALPv0.cdc | 102 ++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 19 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 3ce515ec..1823d2c1 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3206,23 +3206,15 @@ access(all) contract FlowALPv0 { ) // Step 7: Withdraw collateral while maintaining position health - // If there's remaining debt, we need to leave enough collateral to keep position healthy - - // Unlock before withdrawal (withdrawAndPull will lock again) - self._unlockPosition(pid) + // IMPORTANT: Keep position locked throughout withdrawal to prevent race conditions + // Do NOT unlock before withdrawal - we do direct withdrawal while holding the lock // Determine withdrawal amount based on remaining debt - var collateral: @{FungibleToken.Vault}? <- nil + var withdrawAmount: UFix64 = 0.0 if totalEffectiveDebt == 0.0 { // No remaining debt - withdraw all collateral - let fullBalance = UFix64(positionView.trueBalance(ofToken: collateralType)) - collateral <-! self.withdrawAndPull( - pid: pid, - type: collateralType, - amount: fullBalance, - pullFromTopUpSource: false - ) + withdrawAmount = UFix64(positionView.trueBalance(ofToken: collateralType)) } else { // Remaining debt exists - calculate safe withdrawal maintaining target health let position = self._borrowPosition(pid: pid) @@ -3240,15 +3232,40 @@ access(all) contract FlowALPv0 { let totalCollateral = UFix64(positionView.trueBalance(ofToken: collateralType)) // Withdraw total minus minimum (with small buffer for safety) - let safeWithdrawAmount = totalCollateral > minCollateralAmount + 1.0 - ? totalCollateral - minCollateralAmount - 1.0 - : 0.0 - - if safeWithdrawAmount > 0.0 { - collateral <-! self.withdrawAndPull(pid: pid, type: collateralType, amount: safeWithdrawAmount, pullFromTopUpSource: false) + if totalCollateral > minCollateralAmount + 1.0 { + withdrawAmount = totalCollateral - minCollateralAmount - 1.0 } else { - collateral <-! DeFiActionsUtils.getEmptyVault(collateralType) + withdrawAmount = 0.0 + } + } + + // Perform direct withdrawal while holding lock (no health check needed for close) + var collateral: @{FungibleToken.Vault}? <- nil + + if withdrawAmount > 0.0 { + let position = self._borrowPosition(pid: pid) + let tokenState = self._borrowUpdatedTokenState(type: collateralType) + let reserveVault = (&self.reserves[collateralType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! + + // Record withdrawal in position balance + if position.balances[collateralType] == nil { + position.balances[collateralType] = InternalBalance( + direction: BalanceDirection.Credit, + scaledBalance: 0.0 + ) } + position.balances[collateralType]!.recordWithdrawal( + amount: UFix128(withdrawAmount), + tokenState: tokenState + ) + + // Queue for update if necessary + self._queuePositionForUpdateIfNecessary(pid: pid) + + // Withdraw from reserves + collateral <-! reserveVault.withdraw(amount: withdrawAmount) + } else { + collateral <-! DeFiActionsUtils.getEmptyVault(collateralType) } let finalCollateral <- collateral! @@ -3265,6 +3282,17 @@ access(all) contract FlowALPv0 { finalDebt: totalEffectiveDebt ) + emit Withdrawn( + pid: pid, + poolUUID: self.uuid, + vaultType: collateralType, + amount: finalCollateralAmount, + withdrawnUUID: finalCollateral.uuid + ) + + // Unlock position now that all operations are complete + self._unlockPosition(pid) + return <-finalCollateral } @@ -4086,6 +4114,26 @@ access(all) contract FlowALPv0 { return pool.getPositionDetails(pid: self.id).balances } + /// Returns the total debt amount and debt token type for this position. + /// This is a convenience method for strategies to avoid recalculating debt from balances. + /// + /// @return DebtInfo struct with amount and tokenType. If no debt exists, returns DebtInfo(0.0, nil). + access(all) fun getTotalDebt(): DebtInfo { + let pool = self.pool.borrow()! + let balances = pool.getPositionDetails(pid: self.id).balances + var totalDebtAmount: UFix64 = 0.0 + var debtType: Type? = nil + + for balance in balances { + if balance.direction == BalanceDirection.Debit { + totalDebtAmount = totalDebtAmount + UFix64(balance.balance) + debtType = balance.vaultType + } + } + + return DebtInfo(amount: totalDebtAmount, tokenType: debtType) + } + /// Returns the balance available for withdrawal of a given Vault type. If pullFromTopUpSource is true, the /// calculation will be made assuming the position is topped up if the withdrawal amount puts the Position /// below its min health. If pullFromTopUpSource is false, the calculation will return the balance currently @@ -4576,6 +4624,22 @@ access(all) contract FlowALPv0 { /// /// A structure returned externally to report a position's balance for a particular token. /// This structure is NOT used internally. + /// DebtInfo + /// + /// A structure returned by getTotalDebt() to report the total debt and debt token type. + access(all) struct DebtInfo { + /// The total amount of debt + access(all) let amount: UFix64 + + /// The type of the debt token (nil if no debt) + access(all) let tokenType: Type? + + init(amount: UFix64, tokenType: Type?) { + self.amount = amount + self.tokenType = tokenType + } + } + access(all) struct PositionBalance { /// The token type for which the balance details relate to From 76d6b6cbae0da6224b37852d6276deb93067d4df Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:45:31 -0500 Subject: [PATCH 14/56] round up debt --- cadence/contracts/FlowALPv0.cdc | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 1823d2c1..a1371e93 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3094,15 +3094,15 @@ access(all) contract FlowALPv0 { } // Step 1: Calculate total debt that needs to be repaid (NO LOCK NEEDED - read-only) + // Note: Debt is always MOET in this protocol let positionDetails = self.getPositionDetails(pid: pid) var totalDebtAmount: UFix64 = 0.0 - var debtType: Type? = nil + let debtType = Type<@MOET.Vault>() for balance in positionDetails.balances { if balance.direction == BalanceDirection.Debit { - // Accumulate debt (assuming single debt type for now) - totalDebtAmount = totalDebtAmount + UFix64(balance.balance) - debtType = balance.vaultType + // Accumulate debt (balance is already UFix64, no rounding needed here) + totalDebtAmount = totalDebtAmount + balance.balance } } @@ -3179,7 +3179,9 @@ access(all) contract FlowALPv0 { ?? panic("Collateral factor not found for \(collateralType.identifier)") // Remaining debt in USD / (collateral price * collateral factor) = collateral needed - let collateralNeededForDebt = UFix64(totalEffectiveDebt / (UFix128(collateralPrice) * UFix128(collateralFactor))) + // Round UP to ensure protocol keeps enough collateral to cover debt + let collateralNeededRaw = totalEffectiveDebt / (UFix128(collateralPrice) * UFix128(collateralFactor)) + let collateralNeededForDebt = FlowALPMath.toUFix64RoundUp(collateralNeededRaw) // Total available collateral in position let totalCollateralAvailable = UFix64(collateralBalance) @@ -3223,10 +3225,13 @@ access(all) contract FlowALPv0 { // Calculate collateral needed to maintain target health: // (collateralValue * collateralFactor) / (debtValue / borrowFactor) >= targetHealth // collateralValue >= (targetHealth * debtValue) / (collateralFactor * borrowFactor) - let borrowFactor = self.borrowFactor[debtType ?? repaymentType] ?? 1.0 + // Debt is always MOET, so use MOET's borrow factor + let borrowFactor = self.borrowFactor[debtType] ?? 1.0 let minCollateralValue = UFix64(targetHealth) * UFix64(totalEffectiveDebt) / (collateralFactor * borrowFactor) - let minCollateralAmount = minCollateralValue / collateralPrice + // Round UP to ensure protocol keeps enough collateral + let minCollateralAmountRaw = UFix128(minCollateralValue) / UFix128(collateralPrice) + let minCollateralAmount = FlowALPMath.toUFix64RoundUp(minCollateralAmountRaw) // Get total collateral let totalCollateral = UFix64(positionView.trueBalance(ofToken: collateralType)) @@ -4117,21 +4122,24 @@ access(all) contract FlowALPv0 { /// Returns the total debt amount and debt token type for this position. /// This is a convenience method for strategies to avoid recalculating debt from balances. /// - /// @return DebtInfo struct with amount and tokenType. If no debt exists, returns DebtInfo(0.0, nil). + /// Note: Debt is always in MOET in this protocol. + /// Rounds up to ensure protocol doesn't accumulate rounding errors. + /// + /// @return DebtInfo struct with amount (rounded up) and tokenType (always MOET). access(all) fun getTotalDebt(): DebtInfo { let pool = self.pool.borrow()! let balances = pool.getPositionDetails(pid: self.id).balances var totalDebtAmount: UFix64 = 0.0 - var debtType: Type? = nil for balance in balances { if balance.direction == BalanceDirection.Debit { - totalDebtAmount = totalDebtAmount + UFix64(balance.balance) - debtType = balance.vaultType + // Accumulate debt (balance is already UFix64, no rounding needed here) + totalDebtAmount = totalDebtAmount + balance.balance } } - return DebtInfo(amount: totalDebtAmount, tokenType: debtType) + // Debt is always MOET in this protocol + return DebtInfo(amount: totalDebtAmount, tokenType: Type<@MOET.Vault>()) } /// Returns the balance available for withdrawal of a given Vault type. If pullFromTopUpSource is true, the From 94ae8ce654c29eeec57c18d8d100fee2499842d0 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:06:03 -0500 Subject: [PATCH 15/56] repayment balance check --- FlowActions | 2 +- cadence/contracts/FlowALPv0.cdc | 33 ++++++++++++++++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/FlowActions b/FlowActions index 2357ae77..72540c50 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit 2357ae770e6c5cccae65e2965f75b4fba0a64ed9 +Subproject commit 72540c508f9e33bcf37404a15adf7b1652aeea0a diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index a1371e93..0286c9c7 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3114,7 +3114,12 @@ access(all) contract FlowALPv0 { // Handle no-debt case if totalDebtAmount == 0.0 { - // No debt - destroy repayment vault and just withdraw all collateral + // No debt - assert repayment vault is empty before destroying + assert( + repaymentVault.balance == 0.0, + message: "Position has no debt but repayment vault contains \(repaymentVault.balance) \(repaymentType.identifier). ".concat( + "Either withdraw these funds or deposit them to the position separately.") + ) destroy repaymentVault let collateralBalance = self.buildPositionView(pid: pid).trueBalance(ofToken: collateralType) @@ -3139,11 +3144,20 @@ access(all) contract FlowALPv0 { return <-withdrawn } - // Step 3: Accept repayment vault (allow overshoot - extra funds help ensure full repayment) - // Users can provide more than needed to handle rounding/slippage/circular dependencies - // Note: We don't enforce minimum here - we'll check final debt after deposit instead + // Step 3: Validate repayment vault and handle precision shortfalls + // Assert repayment vault is correct token type (MOET) + assert( + repaymentType == debtType, + message: "Repayment vault type mismatch. Expected: \(debtType.identifier), Got: \(repaymentType.identifier)" + ) + + assert( + repaymentVault.balance >= totalDebtAmount, + message: "Repayment should cover full debt amount" + ) // Step 4: Deposit repayment funds to eliminate debt (under lock) + // Note: _depositEffectsOnly consumes the entire vault self._depositEffectsOnly(pid: pid, from: <-repaymentVault) // Step 5: Verify debt is acceptably low (allow tolerance for overshoot scenarios) @@ -4123,9 +4137,10 @@ access(all) contract FlowALPv0 { /// This is a convenience method for strategies to avoid recalculating debt from balances. /// /// Note: Debt is always in MOET in this protocol. - /// Rounds up to ensure protocol doesn't accumulate rounding errors. + /// Returns exact debt amount - no buffer needed since measurement and repayment happen + /// in the same transaction (no interest accrual between reads). /// - /// @return DebtInfo struct with amount (rounded up) and tokenType (always MOET). + /// @return DebtInfo struct with exact debt amount and tokenType (always MOET). access(all) fun getTotalDebt(): DebtInfo { let pool = self.pool.borrow()! let balances = pool.getPositionDetails(pid: self.id).balances @@ -4133,12 +4148,16 @@ access(all) contract FlowALPv0 { for balance in balances { if balance.direction == BalanceDirection.Debit { - // Accumulate debt (balance is already UFix64, no rounding needed here) + // Accumulate debt amount totalDebtAmount = totalDebtAmount + balance.balance } } // Debt is always MOET in this protocol + // NOTE: Strategies using this must ensure their swap sources have sufficient + // liquidity. SwapSource.minimumAvailable() may return slightly less than + // actual debt due to source liquidity constraints or precision loss in + // swap calculations. Strategies should handle this appropriately. return DebtInfo(amount: totalDebtAmount, tokenType: Type<@MOET.Vault>()) } From 77ab388da0aac346990783230e20d19292d819ed Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:12:58 -0500 Subject: [PATCH 16/56] update deps --- flow.json | 48 +++++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/flow.json b/flow.json index 9feaf04b..a6df7a4e 100644 --- a/flow.json +++ b/flow.json @@ -117,7 +117,7 @@ "Burner": { "source": "mainnet://f233dcee88fe0abe.Burner", "hash": "71af18e227984cd434a3ad00bb2f3618b76482842bae920ee55662c37c8bf331", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "f233dcee88fe0abe", @@ -128,7 +128,7 @@ "EVM": { "source": "mainnet://e467b9dd11fa00df.EVM", "hash": "960b0c7df7ee536956af196fba8c8d5dd4f7a89a4ecc61467e31287c4617b0dd", - "block_height": 141019535, + "block_height": 143394405, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -139,7 +139,7 @@ "FlowCron": { "source": "mainnet://6dec6e64a13b881e.FlowCron", "hash": "ab570aabfb4d3ee01537ad85ad789ed13ac193ba447bc037365d51d748272cd5", - "block_height": 141024643, + "block_height": 143394405, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "6dec6e64a13b881e", @@ -151,7 +151,7 @@ "FlowCronUtils": { "source": "mainnet://6dec6e64a13b881e.FlowCronUtils", "hash": "498c32c1345b9b1db951a18e4ea94325b8c9c05ea691f2d9b6af75b886ab51a2", - "block_height": 141024643, + "block_height": 143394405, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "6dec6e64a13b881e", @@ -163,7 +163,7 @@ "FlowFees": { "source": "mainnet://f919ee77447b7497.FlowFees", "hash": "341cc0f3cc847d6b787c390133f6a5e6c867c111784f09c5c0083c47f2f1df64", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "e5a8b7f23e8b548f", "mainnet": "f919ee77447b7497", @@ -174,7 +174,7 @@ "FlowStorageFees": { "source": "mainnet://e467b9dd11fa00df.FlowStorageFees", "hash": "a92c26fb2ea59725441fa703aa4cd811e0fc56ac73d649a8e12c1e72b67a8473", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -185,7 +185,7 @@ "FlowToken": { "source": "mainnet://1654653399040a61.FlowToken", "hash": "f82389e2412624ffa439836b00b42e6605b0c00802a4e485bc95b8930a7eac38", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "0ae53cb6e3f42a79", "mainnet": "1654653399040a61", @@ -196,7 +196,7 @@ "FlowTransactionScheduler": { "source": "mainnet://e467b9dd11fa00df.FlowTransactionScheduler", "hash": "23157cf7d70534e45b0ab729133232d0ffb3cdae52661df1744747cb1f8c0495", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -207,7 +207,7 @@ "FlowTransactionSchedulerUtils": { "source": "mainnet://e467b9dd11fa00df.FlowTransactionSchedulerUtils", "hash": "71a1febab6b9ba76abec36dab1e61b1c377e44fbe627e5fac649deb71b727877", - "block_height": 141019535, + "block_height": 143394405, "aliases": { "mainnet": "e467b9dd11fa00df", "mainnet-fork": "e467b9dd11fa00df" @@ -216,7 +216,7 @@ "FungibleToken": { "source": "mainnet://f233dcee88fe0abe.FungibleToken", "hash": "4b74edfe7d7ddfa70b703c14aa731a0b2e7ce016ce54d998bfd861ada4d240f6", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", @@ -227,7 +227,7 @@ "FungibleTokenMetadataViews": { "source": "mainnet://f233dcee88fe0abe.FungibleTokenMetadataViews", "hash": "70477f80fd7678466c224507e9689f68f72a9e697128d5ea54d19961ec856b3c", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", @@ -238,7 +238,7 @@ "MetadataViews": { "source": "mainnet://1d7e57aa55817448.MetadataViews", "hash": "b290b7906d901882b4b62e596225fb2f10defb5eaaab4a09368f3aee0e9c18b1", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -249,7 +249,7 @@ "NonFungibleToken": { "source": "mainnet://1d7e57aa55817448.NonFungibleToken", "hash": "a258de1abddcdb50afc929e74aca87161d0083588f6abf2b369672e64cf4a403", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -260,7 +260,7 @@ "ViewResolver": { "source": "mainnet://1d7e57aa55817448.ViewResolver", "hash": "374a1994046bac9f6228b4843cb32393ef40554df9bd9907a702d098a2987bde", - "block_height": 139085361, + "block_height": 143394405, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -295,14 +295,6 @@ "resourceID": "projects/dl-flow-devex-production/locations/us-central1/keyRings/tidal-keyring/cryptoKeys/tidal_admin_pk/cryptoKeyVersions/1" } }, - "mainnet-fyv-deployer": { - "address": "b1d63873c3cc9f79", - "key": { - "type": "google-kms", - "hashAlgorithm": "SHA2_256", - "resourceID": "projects/dl-flow-devex-production/locations/us-central1/keyRings/tidal-keyring/cryptoKeys/tidal_admin_pk/cryptoKeyVersions/1" - } - }, "mainnet-fork-deployer": { "address": "6b00ff876c299c61", "key": { @@ -317,6 +309,14 @@ "location": "emulator-account.pkey" } }, + "mainnet-fyv-deployer": { + "address": "b1d63873c3cc9f79", + "key": { + "type": "google-kms", + "hashAlgorithm": "SHA2_256", + "resourceID": "projects/dl-flow-devex-production/locations/us-central1/keyRings/tidal-keyring/cryptoKeys/tidal_admin_pk/cryptoKeyVersions/1" + } + }, "testnet-deployer": { "address": "426f0458ced60037", "key": { @@ -370,7 +370,6 @@ "MockDexSwapper", "MockOracle" ] - }, "mainnet-fork": { "mainnet-fork-deployer": [ @@ -390,7 +389,6 @@ "MockDexSwapper", "MockOracle" ] - }, "testnet": { "testnet-deployer": [ @@ -412,4 +410,4 @@ ] } } -} +} \ No newline at end of file From ed16a72e8394c3236f558c6943fa00da95329986 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:28:20 -0500 Subject: [PATCH 17/56] revert unnecessary changes --- cadence/contracts/FlowALPv0.cdc | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 0286c9c7..0f70210a 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3009,23 +3009,11 @@ access(all) contract FlowALPv0 { tokenState: tokenState ) - // Check if we're withdrawing a debt token (debit balance) - let isDebtToken = position.balances[type]!.direction == BalanceDirection.Debit - - // Attempt to pull additional collateral from the top-up source (if configured) - // to keep the position above minHealth after the withdrawal. - // - // IMPORTANT: Skip health check if withdrawing debt token with pullFromTopUpSource=true - // In this case, we're getting funds for external use (e.g., closePosition repayment), - // and temporarily increasing debt is expected as part of the close flow. - // The debt will be immediately repaid by the calling context (closePosition). - if !isDebtToken || !pullFromTopUpSource { - let postHealth = self.positionHealth(pid: pid) - assert( - postHealth >= 1.0, - message: "Post-withdrawal position health (\(postHealth)) is unhealthy" - ) - } + let postHealth = self.positionHealth(pid: pid) + assert( + postHealth >= 1.0, + message: "Post-withdrawal position health (\(postHealth)) is unhealthy" + ) // Ensure that the remaining balance meets the minimum requirement (or is zero) // Building the position view does require copying the balances, so it's less efficient than accessing the balance directly. @@ -3153,7 +3141,7 @@ access(all) contract FlowALPv0 { assert( repaymentVault.balance >= totalDebtAmount, - message: "Repayment should cover full debt amount" + message: "Repayment should cover full debt amount provided: \(repaymentVault.balance.toString()), required: \(totalDebtAmount.toString())" ) // Step 4: Deposit repayment funds to eliminate debt (under lock) From ad92e4462dbe93c91452009b067b2f14e62f6111 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:28:57 -0500 Subject: [PATCH 18/56] Apply suggestion from @nialexsan --- cadence/contracts/FlowALPv0.cdc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 0f70210a..9cf5edaf 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3008,7 +3008,9 @@ access(all) contract FlowALPv0 { amount: uintAmount, tokenState: tokenState ) - + // Attempt to pull additional collateral from the top-up source (if configured) + // to keep the position above minHealth after the withdrawal. + // Regardless of whether a top-up occurs, the position must be healthy post-withdrawal. let postHealth = self.positionHealth(pid: pid) assert( postHealth >= 1.0, From ebf1c8c5efd6aece51842e03344f72f624131cb4 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:31:54 -0500 Subject: [PATCH 19/56] remove buffer --- cadence/contracts/FlowALPv0.cdc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 9cf5edaf..c7b0a735 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3240,9 +3240,9 @@ access(all) contract FlowALPv0 { // Get total collateral let totalCollateral = UFix64(positionView.trueBalance(ofToken: collateralType)) - // Withdraw total minus minimum (with small buffer for safety) - if totalCollateral > minCollateralAmount + 1.0 { - withdrawAmount = totalCollateral - minCollateralAmount - 1.0 + // Withdraw total minus minimum + if totalCollateral > minCollateralAmount { + withdrawAmount = totalCollateral - minCollateralAmount } else { withdrawAmount = 0.0 } From 253be6f1ab79517ed3132ebc7ff01b953df2a247 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:17:38 -0500 Subject: [PATCH 20/56] close position test --- .../tests/close_position_precision_test.cdc | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 cadence/tests/close_position_precision_test.cdc diff --git a/cadence/tests/close_position_precision_test.cdc b/cadence/tests/close_position_precision_test.cdc new file mode 100644 index 00000000..15a25b66 --- /dev/null +++ b/cadence/tests/close_position_precision_test.cdc @@ -0,0 +1,418 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowALPv0" +import "FlowALPMath" +import "test_helpers.cdc" + +// ----------------------------------------------------------------------------- +// Close Position Precision Test Suite +// +// Tests close position functionality with focus on: +// 1. Balance increases (collateral appreciation) +// 2. Balance falls (collateral depreciation) +// 3. Rounding precision and shortfall tolerance +// ----------------------------------------------------------------------------- + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun setup() { + deployContracts() + snapshot = getCurrentBlockHeight() +} + +// ============================================================================= +// Test 1: Close position with no debt +// ============================================================================= +access(all) +fun test_closePosition_noDebt() { + log("\n=== Test: Close Position with No Debt ===") + + // Setup: price = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Create pool & enable token + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + addSupportedTokenZeroRateCurve(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, collateralFactor: 0.8, borrowFactor: 1.0, depositRate: 1_000_000.0, depositCapacityCap: 1_000_000.0) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with pushToDrawDownSink = false (no debt) + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Verify no MOET was borrowed + let moetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + Test.assertEqual(0.0, moetBalance) + + // Mint tiny buffer to handle any precision shortfall + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) + + // Close position (ID 0) + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + log("✅ Successfully closed position with no debt") +} + +// ============================================================================= +// Test 2: Close position with debt +// ============================================================================= +access(all) +fun test_closePosition_withDebt() { + log("\n=== Test: Close Position with Debt ===") + + // Reset price to 1.0 for this test + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Reuse existing pool from previous test + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with pushToDrawDownSink = true (creates debt) + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Verify MOET was borrowed + let moetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + log("Borrowed MOET: \(moetBalance)") + Test.assert(moetBalance > 0.0) + + // Mint tiny buffer to handle any precision shortfall + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) + + // Close position (ID 1 since test 1 created position 0) + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(1)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + log("✅ Successfully closed position with debt: \(moetBalance) MOET") +} + +// ============================================================================= +// Test 3: Close after collateral price increase (balance increases) +// ============================================================================= +access(all) +fun test_closePosition_afterPriceIncrease() { + log("\n=== Test: Close After Collateral Price Increase (Balance Increases) ===") + + // Reset price to 1.0 for this test + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Reuse existing pool from previous test + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + let detailsBefore = getPositionDetails(pid: 2, beFailed: false) + log("Health before price increase: \(detailsBefore.health)") + + // Increase FLOW price to 1.5 (50% gain) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.5) + log("Increased FLOW price to $1.5 (+50%)") + + let detailsAfter = getPositionDetails(pid: 2, beFailed: false) + log("Health after price increase: \(detailsAfter.health)") + Test.assert(detailsAfter.health > detailsBefore.health) + + // Mint tiny buffer to handle any precision shortfall + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) + + // Close position + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(2)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + log("✅ Successfully closed after collateral appreciation (balance increased)") +} + +// ============================================================================= +// Test 4: Close after collateral price decrease (balance falls) +// ============================================================================= +access(all) +fun test_closePosition_afterPriceDecrease() { + log("\n=== Test: Close After Collateral Price Decrease (Balance Falls) ===") + + // Reset price to 1.0 for this test + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Reuse existing pool from previous test + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + let detailsBefore = getPositionDetails(pid: 3, beFailed: false) + log("Health before price decrease: \(detailsBefore.health)") + + // Decrease FLOW price to 0.8 (20% loss) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.8) + log("Decreased FLOW price to $0.8 (-20%)") + + let detailsAfter = getPositionDetails(pid: 3, beFailed: false) + log("Health after price decrease: \(detailsAfter.health)") + Test.assert(detailsAfter.health < detailsBefore.health) + + // Mint tiny buffer to handle any precision shortfall + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) + + // Close position (should still succeed) + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(3)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + log("✅ Successfully closed after collateral depreciation (balance fell)") +} + +// ============================================================================= +// Test 5: Close with precision shortfall after multiple rebalances +// ============================================================================= +access(all) +fun test_closePosition_precisionShortfall_multipleRebalances() { + log("\n=== Test: Close with Precision Shortfall (Multiple Rebalances) ===") + + // Reset price to 1.0 for this test + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Reuse existing pool from previous test + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Perform rebalances with varying prices to accumulate rounding errors + log("\nRebalance 1: FLOW price = $1.2") + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.2) + let reb1 = _executeTransaction("../transactions/flow-alp/pool-management/rebalance_position.cdc", [UInt64(4), true], PROTOCOL_ACCOUNT) + Test.expect(reb1, Test.beSucceeded()) + + log("\nRebalance 2: FLOW price = $1.9") + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.9) + let reb2 = _executeTransaction("../transactions/flow-alp/pool-management/rebalance_position.cdc", [UInt64(4), true], PROTOCOL_ACCOUNT) + Test.expect(reb2, Test.beSucceeded()) + + log("\nRebalance 3: FLOW price = $1.5") + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.5) + let reb3 = _executeTransaction("../transactions/flow-alp/pool-management/rebalance_position.cdc", [UInt64(4), true], PROTOCOL_ACCOUNT) + Test.expect(reb3, Test.beSucceeded()) + + // Get final position state + let finalDetails = getPositionDetails(pid: 4, beFailed: false) + log("\n--- Final State ---") + log("Health: \(finalDetails.health)") + logBalances(finalDetails.balances) + + // Mint tiny buffer to handle any precision shortfall + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) + + // Close position - may have tiny shortfall due to accumulated rounding + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(4)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + log("✅ Successfully closed after 3 rebalances (precision shortfall automatically handled)") +} + +// ============================================================================= +// Test 6: Demonstrate precision with extreme volatility +// ============================================================================= +access(all) +fun test_closePosition_extremeVolatility() { + log("\n=== Test: Close After Extreme Price Volatility ===") + + // Reset price to 1.0 for this test + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Reuse existing pool from previous test + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Simulate extreme volatility: 5x gains, 90% drops + let extremePrices: [UFix64] = [5.0, 0.5, 3.0, 0.2, 4.0, 0.1, 2.0] + + var volCount = 1 + for price in extremePrices { + log("\nExtreme volatility \(volCount): FLOW = $\(price)") + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: price) + + let rebalanceRes = _executeTransaction( + "../transactions/flow-alp/pool-management/rebalance_position.cdc", + [UInt64(5), true], + PROTOCOL_ACCOUNT + ) + Test.expect(rebalanceRes, Test.beSucceeded()) + + let details = getPositionDetails(pid: 5, beFailed: false) + log("Health: \(details.health)") + volCount = volCount + 1 + } + + log("\n--- Closing after extreme volatility ---") + + // Mint larger buffer for extreme volatility test (accumulated errors from 7 rebalances) + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 1.0, beFailed: false) + + // Close position + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(5)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + log("✅ Successfully closed after extreme volatility (balance increased/fell dramatically)") +} + +// ============================================================================= +// Test 7: Close with minimal debt (edge case) +// ============================================================================= +access(all) +fun test_closePosition_minimalDebt() { + log("\n=== Test: Close with Minimal Debt ===") + + // Reset price to 1.0 for this test + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Reuse existing pool from previous test + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with minimal amount + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [1.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + let moetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + log("Minimal debt amount: \(moetBalance) MOET") + + // Mint tiny buffer to handle any precision shortfall + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) + + // Close position + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(6)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + log("✅ Successfully closed with minimal debt") +} + +// ============================================================================= +// Test 8: Demonstrate UFix64 precision limits +// ============================================================================= +access(all) +fun test_precision_demonstration() { + log("\n=== UFix64/UFix128 Precision Demonstration ===") + + // Demonstrate UFix64 precision (8 decimal places) + let value1: UFix64 = 1.00000001 + let value2: UFix64 = 1.00000002 + log("UFix64 minimum precision: 0.00000001") + log("Value 1: \(value1)") + log("Value 2: \(value2)") + log("Difference: \(value2 - value1)") + + // Demonstrate UFix128 intermediate precision + let uintValue1 = UFix128(1.23456789) + let uintValue2 = UFix128(9.87654321) + let product = uintValue1 * uintValue2 + log("\nUFix128 calculation: \(uintValue1) * \(uintValue2) = \(product)") + + // Demonstrate precision loss when converting UFix128 → UFix64 + let rounded = FlowALPMath.toUFix64Round(product) + let roundedUp = FlowALPMath.toUFix64RoundUp(product) + let roundedDown = FlowALPMath.toUFix64RoundDown(product) + log("Converting \(product) to UFix64:") + log(" Round (nearest): \(rounded)") + log(" Round Up: \(roundedUp)") + log(" Round Down: \(roundedDown)") + log(" Precision loss range: \(roundedUp - roundedDown)") + + log("\n✅ Precision demonstration complete") + log("Key insight: Each UFix128→UFix64 conversion loses up to 0.00000001") + log("Multiple operations accumulate this loss, requiring shortfall tolerance") +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +access(all) fun logBalances(_ balances: [FlowALPv0.PositionBalance]) { + for balance in balances { + let direction = balance.direction == FlowALPv0.BalanceDirection.Credit ? "Credit" : "Debit" + log(" \(direction): \(balance.balance) of \(balance.vaultType.identifier)") + } +} From ca37d21b2fc6992e065c2d6e777445b01f0007a5 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:18:57 -0500 Subject: [PATCH 21/56] add safe rounding --- cadence/contracts/FlowALPv0.cdc | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index c7b0a735..87fbd36e 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1892,10 +1892,17 @@ access(all) contract FlowALPv0 { : tokenState.debitInterestIndex ) + // Conservative rounding: + // - Debits (debt/withdrawals from position): round UP to ensure we require enough + // - Credits (deposits/collateral): round DOWN to avoid overpromising available funds + let balanceUFix64 = balance.direction == BalanceDirection.Debit + ? FlowALPMath.toUFix64RoundUp(trueBalance) + : FlowALPMath.toUFix64RoundDown(trueBalance) + balances.append(PositionBalance( vaultType: type, direction: balance.direction, - balance: FlowALPMath.toUFix64Round(trueBalance) + balance: balanceUFix64 )) } @@ -3085,13 +3092,15 @@ access(all) contract FlowALPv0 { // Step 1: Calculate total debt that needs to be repaid (NO LOCK NEEDED - read-only) // Note: Debt is always MOET in this protocol + // Use standard position details which applies conservative rounding (UP for debits) + // to ensure protocol safety - we always require full repayment of debt let positionDetails = self.getPositionDetails(pid: pid) var totalDebtAmount: UFix64 = 0.0 let debtType = Type<@MOET.Vault>() for balance in positionDetails.balances { if balance.direction == BalanceDirection.Debit { - // Accumulate debt (balance is already UFix64, no rounding needed here) + // Accumulate debt (balance is already UFix64 with conservative rounding applied) totalDebtAmount = totalDebtAmount + balance.balance } } @@ -3220,7 +3229,9 @@ access(all) contract FlowALPv0 { if totalEffectiveDebt == 0.0 { // No remaining debt - withdraw all collateral - withdrawAmount = UFix64(positionView.trueBalance(ofToken: collateralType)) + // Round DOWN to ensure we never try to withdraw more than what's in the vault + // (UFix128→UFix64 conversion can introduce precision errors) + withdrawAmount = FlowALPMath.toUFix64RoundDown(positionView.trueBalance(ofToken: collateralType)) } else { // Remaining debt exists - calculate safe withdrawal maintaining target health let position = self._borrowPosition(pid: pid) From edf96dc19d51f613328a62bcf51ba25121d37b07 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:34:54 -0500 Subject: [PATCH 22/56] fix merge --- flow.json | 58 +------------------------------------------------------ 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/flow.json b/flow.json index 7a27a3f7..7d8baff7 100644 --- a/flow.json +++ b/flow.json @@ -117,11 +117,7 @@ "Burner": { "source": "mainnet://f233dcee88fe0abe.Burner", "hash": "71af18e227984cd434a3ad00bb2f3618b76482842bae920ee55662c37c8bf331", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "f233dcee88fe0abe", @@ -132,11 +128,7 @@ "EVM": { "source": "mainnet://e467b9dd11fa00df.EVM", "hash": "960b0c7df7ee536956af196fba8c8d5dd4f7a89a4ecc61467e31287c4617b0dd", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -147,11 +139,7 @@ "FlowCron": { "source": "mainnet://6dec6e64a13b881e.FlowCron", "hash": "ab570aabfb4d3ee01537ad85ad789ed13ac193ba447bc037365d51d748272cd5", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "6dec6e64a13b881e", @@ -163,11 +151,7 @@ "FlowCronUtils": { "source": "mainnet://6dec6e64a13b881e.FlowCronUtils", "hash": "498c32c1345b9b1db951a18e4ea94325b8c9c05ea691f2d9b6af75b886ab51a2", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "6dec6e64a13b881e", @@ -179,11 +163,7 @@ "FlowFees": { "source": "mainnet://f919ee77447b7497.FlowFees", "hash": "341cc0f3cc847d6b787c390133f6a5e6c867c111784f09c5c0083c47f2f1df64", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "e5a8b7f23e8b548f", "mainnet": "f919ee77447b7497", @@ -194,11 +174,7 @@ "FlowStorageFees": { "source": "mainnet://e467b9dd11fa00df.FlowStorageFees", "hash": "a92c26fb2ea59725441fa703aa4cd811e0fc56ac73d649a8e12c1e72b67a8473", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -209,11 +185,7 @@ "FlowToken": { "source": "mainnet://1654653399040a61.FlowToken", "hash": "f82389e2412624ffa439836b00b42e6605b0c00802a4e485bc95b8930a7eac38", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "0ae53cb6e3f42a79", "mainnet": "1654653399040a61", @@ -224,11 +196,7 @@ "FlowTransactionScheduler": { "source": "mainnet://e467b9dd11fa00df.FlowTransactionScheduler", "hash": "23157cf7d70534e45b0ab729133232d0ffb3cdae52661df1744747cb1f8c0495", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -239,11 +207,7 @@ "FlowTransactionSchedulerUtils": { "source": "mainnet://e467b9dd11fa00df.FlowTransactionSchedulerUtils", "hash": "71a1febab6b9ba76abec36dab1e61b1c377e44fbe627e5fac649deb71b727877", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "mainnet": "e467b9dd11fa00df", "mainnet-fork": "e467b9dd11fa00df" @@ -252,11 +216,7 @@ "FungibleToken": { "source": "mainnet://f233dcee88fe0abe.FungibleToken", "hash": "4b74edfe7d7ddfa70b703c14aa731a0b2e7ce016ce54d998bfd861ada4d240f6", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", @@ -267,11 +227,7 @@ "FungibleTokenMetadataViews": { "source": "mainnet://f233dcee88fe0abe.FungibleTokenMetadataViews", "hash": "70477f80fd7678466c224507e9689f68f72a9e697128d5ea54d19961ec856b3c", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", @@ -282,11 +238,7 @@ "MetadataViews": { "source": "mainnet://1d7e57aa55817448.MetadataViews", "hash": "b290b7906d901882b4b62e596225fb2f10defb5eaaab4a09368f3aee0e9c18b1", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -297,11 +249,7 @@ "NonFungibleToken": { "source": "mainnet://1d7e57aa55817448.NonFungibleToken", "hash": "a258de1abddcdb50afc929e74aca87161d0083588f6abf2b369672e64cf4a403", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -312,11 +260,7 @@ "ViewResolver": { "source": "mainnet://1d7e57aa55817448.ViewResolver", "hash": "374a1994046bac9f6228b4843cb32393ef40554df9bd9907a702d098a2987bde", -<<<<<<< nialexsan/relax-remaining-balance - "block_height": 143394405, -======= "block_height": 143445158, ->>>>>>> main "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -466,4 +410,4 @@ ] } } -} \ No newline at end of file +} From 0b1831cd735fb3f71d1586a5c917bd3b58c841cd Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:44:05 -0500 Subject: [PATCH 23/56] use vaults array --- cadence/contracts/FlowALPv0.cdc | 350 ++++++++++++++------------------ 1 file changed, 148 insertions(+), 202 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 87fbd36e..250179ae 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3053,119 +3053,114 @@ access(all) contract FlowALPv0 { return <- withdrawn } + /// Helper function to process and consume repayment vaults array + /// Returns the total value deposited + access(self) fun _processRepaymentVaults(pid: UInt64, vaults: @[{FungibleToken.Vault}]): UFix64 { + var totalValue: UFix64 = 0.0 + let count = vaults.length + + var i = 0 + while i < count { + let vault <- vaults.remove(at: 0) + let balance = vault.balance + + if balance > 0.0 { + self._depositEffectsOnly(pid: pid, from: <-vault) + totalValue = totalValue + balance + } else { + destroy vault + } + i = i + 1 + } + + destroy vaults + return totalValue + } + /// Closes a position using the position's configured topUpSource for debt repayment. /// This is a convenience method that accesses the topUpSource directly. - /// Closes a position by repaying all debt with a pre-prepared vault and returning all collateral. + /// Closes a position by repaying all debts with pre-prepared vaults and returning all collateral. /// /// This is the ONLY close method - users must prepare repayment funds externally. /// This design eliminates circular dependencies and gives users full control over fund sourcing. /// /// Steps: - /// 1. Calculates total debt (read-only, no lock) + /// 1. Analyzes position to find all debt and collateral types (read-only, no lock) /// 2. Locks the position - /// 3. Deposits repayment vault to eliminate debt + /// 3. Deposits repayment vaults to eliminate debts /// 4. Verifies debt is fully repaid (near-zero) - /// 5. Withdraws ALL remaining collateral (including dust) - /// 6. Returns collateral vault + /// 5. Automatically withdraws ALL collateral types (including dust) + /// 6. Returns array of collateral vaults (one per collateral type found in position) /// /// @param pid: Position ID to close - /// @param repaymentVault: Vault containing funds to repay all debt (pass empty vault if no debt) - /// @param collateralType: Type of collateral to extract and return (e.g., FlowToken) - /// @return Vault containing all collateral including dust + /// @param repaymentVaults: Array of vaults containing funds to repay all debts (pass empty array if no debt) + /// @return Array of vaults containing all collateral (one vault per collateral type in the position) /// access(EPosition) fun closePosition( pid: UInt64, - repaymentVault: @{FungibleToken.Vault}, - collateralType: Type - ): @{FungibleToken.Vault} { - pre { - !self.isPausedOrWarmup(): "Operations are paused by governance" - self.positions[pid] != nil: "Invalid position ID" - } + repaymentVaults: @[{FungibleToken.Vault}] + ): @[{FungibleToken.Vault}] { post { self.positionLock[pid] == nil: "Position is not unlocked" } + // Manual validation (replacing pre conditions to avoid resource handling issues) + assert(!self.isPausedOrWarmup(), message: "Operations are paused by governance") + assert(self.positions[pid] != nil, message: "Invalid position ID") + if self.debugLogging { - log(" [CONTRACT] closePosition(pid: \(pid), collateralType: \(collateralType.identifier))") + log(" [CONTRACT] closePosition(pid: \(pid), repaymentVaults: \(repaymentVaults.length))") } - // Step 1: Calculate total debt that needs to be repaid (NO LOCK NEEDED - read-only) - // Note: Debt is always MOET in this protocol - // Use standard position details which applies conservative rounding (UP for debits) - // to ensure protocol safety - we always require full repayment of debt + // Step 1: Analyze position to find all debt and collateral types let positionDetails = self.getPositionDetails(pid: pid) - var totalDebtAmount: UFix64 = 0.0 - let debtType = Type<@MOET.Vault>() + let debtsByType: {Type: UFix64} = {} + let collateralTypes: [Type] = [] for balance in positionDetails.balances { if balance.direction == BalanceDirection.Debit { - // Accumulate debt (balance is already UFix64 with conservative rounding applied) - totalDebtAmount = totalDebtAmount + balance.balance + let debtType = balance.vaultType + let currentDebt = debtsByType[debtType] ?? 0.0 + debtsByType[debtType] = currentDebt + balance.balance + } else if balance.direction == BalanceDirection.Credit { + // Track collateral types (only if they have a balance) + if balance.balance > 0.0 { + collateralTypes.append(balance.vaultType) + } } } - let actualRepayment = repaymentVault.balance - let repaymentType = repaymentVault.getType() - // Step 2: Lock the position for all state modifications self._lockPosition(pid) - // Handle no-debt case - if totalDebtAmount == 0.0 { - // No debt - assert repayment vault is empty before destroying - assert( - repaymentVault.balance == 0.0, - message: "Position has no debt but repayment vault contains \(repaymentVault.balance) \(repaymentType.identifier). ".concat( - "Either withdraw these funds or deposit them to the position separately.") - ) - destroy repaymentVault - - let collateralBalance = self.buildPositionView(pid: pid).trueBalance(ofToken: collateralType) - let withdrawn <- self.withdrawAndPull( - pid: pid, - type: collateralType, - amount: UFix64(collateralBalance), - pullFromTopUpSource: false - ) + // Step 3: Process repayment vaults inline + var totalRepaymentValue: UFix64 = 0.0 + let repaymentVaultsLength = repaymentVaults.length - emit PositionClosed( - pid: pid, - poolUUID: self.uuid, - repaymentAmount: 0.0, - repaymentType: collateralType, - collateralAmount: withdrawn.balance, - collateralType: collateralType, - finalDebt: 0.0 - ) - - self._unlockPosition(pid) - return <-withdrawn + // Consume all vaults from the array one by one + while true { + if repaymentVaults.length == 0 { + break + } + let vault <- repaymentVaults.removeLast() + let balance = vault.balance + if balance > 0.0 { + self._depositEffectsOnly(pid: pid, from: <-vault) + totalRepaymentValue = totalRepaymentValue + balance + } else { + destroy vault + } } - // Step 3: Validate repayment vault and handle precision shortfalls - // Assert repayment vault is correct token type (MOET) - assert( - repaymentType == debtType, - message: "Repayment vault type mismatch. Expected: \(debtType.identifier), Got: \(repaymentType.identifier)" - ) - - assert( - repaymentVault.balance >= totalDebtAmount, - message: "Repayment should cover full debt amount provided: \(repaymentVault.balance.toString()), required: \(totalDebtAmount.toString())" - ) - - // Step 4: Deposit repayment funds to eliminate debt (under lock) - // Note: _depositEffectsOnly consumes the entire vault - self._depositEffectsOnly(pid: pid, from: <-repaymentVault) + // Array is now empty + destroy repaymentVaults - // Step 5: Verify debt is acceptably low (allow tolerance for overshoot scenarios) + // Step 4: Verify debt is acceptably low (allow tolerance for overshoot scenarios) let updatedDetails = self.getPositionDetails(pid: pid) var totalEffectiveDebt: UFix128 = 0.0 for balance in updatedDetails.balances { if balance.direction == BalanceDirection.Debit { - // Calculate effective debt: (debit * price) / borrowFactor let price = self.priceOracle.price(ofToken: balance.vaultType) ?? panic("Price not available for token \(balance.vaultType.identifier)") let borrowFactor = self.borrowFactor[balance.vaultType] @@ -3180,140 +3175,81 @@ access(all) contract FlowALPv0 { } } - // Step 6: Calculate how much collateral to return - // If there's remaining debt (e.g., from circular dependency), leave enough collateral to cover it + // Step 5: Withdraw all collateral types let positionView = self.buildPositionView(pid: pid) - let collateralBalance = positionView.trueBalance(ofToken: collateralType) + let collateralVaults: @[{FungibleToken.Vault}] <- [] + var totalCollateralValue: UFix64 = 0.0 - // Calculate collateral value needed to cover remaining debt - let collateralPrice = self.priceOracle.price(ofToken: collateralType) - ?? panic("Price not available for collateral \(collateralType.identifier)") - let collateralFactor = self.collateralFactor[collateralType] - ?? panic("Collateral factor not found for \(collateralType.identifier)") + for collateralType in collateralTypes { + let collateralBalance = positionView.trueBalance(ofToken: collateralType) - // Remaining debt in USD / (collateral price * collateral factor) = collateral needed - // Round UP to ensure protocol keeps enough collateral to cover debt - let collateralNeededRaw = totalEffectiveDebt / (UFix128(collateralPrice) * UFix128(collateralFactor)) - let collateralNeededForDebt = FlowALPMath.toUFix64RoundUp(collateralNeededRaw) + if collateralBalance == 0.0 { + // No balance for this collateral type - return empty vault + collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(collateralType)) + continue + } - // Total available collateral in position - let totalCollateralAvailable = UFix64(collateralBalance) + // Calculate collateral price and factor + let collateralPrice = self.priceOracle.price(ofToken: collateralType) + ?? panic("Price not available for collateral \(collateralType.identifier)") - // If remaining debt requires more collateral than available, that's an error - assert( - collateralNeededForDebt <= totalCollateralAvailable, - message: "Insufficient collateral to cover remaining debt. Debt requires \(collateralNeededForDebt) collateral but only \(totalCollateralAvailable) available. ".concat( - "Remaining debt: \(totalEffectiveDebt) USD. Please provide additional repayment funds.") - ) + // Determine withdrawal amount - withdraw all collateral for this type + let withdrawAmount = FlowALPMath.toUFix64RoundDown(collateralBalance) - // Collateral to return = total collateral - collateral covering remaining debt - let collateralToReturn = totalCollateralAvailable - collateralNeededForDebt + // Perform direct withdrawal while holding lock + if withdrawAmount > 0.0 { + let position = self._borrowPosition(pid: pid) + let tokenState = self._borrowUpdatedTokenState(type: collateralType) + let reserveVault = (&self.reserves[collateralType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - // If there's no remaining debt, return all collateral - // If there is remaining debt, return reduced collateral (leaving debt coverage in position) - let withdrawableCollateral = totalEffectiveDebt > 0.0 - ? collateralToReturn - : totalCollateralAvailable + // Record withdrawal in position balance + if position.balances[collateralType] == nil { + position.balances[collateralType] = InternalBalance( + direction: BalanceDirection.Credit, + scaledBalance: 0.0 + ) + } + position.balances[collateralType]!.recordWithdrawal( + amount: UFix128(withdrawAmount), + tokenState: tokenState + ) - assert( - withdrawableCollateral > 0.0, - message: "No collateral available to return. All collateral needed to cover remaining debt: \(totalEffectiveDebt) USD" - ) + // Queue for update if necessary + self._queuePositionForUpdateIfNecessary(pid: pid) - // Step 7: Withdraw collateral while maintaining position health - // IMPORTANT: Keep position locked throughout withdrawal to prevent race conditions - // Do NOT unlock before withdrawal - we do direct withdrawal while holding the lock + // Withdraw from reserves + let withdrawn <- reserveVault.withdraw(amount: withdrawAmount) + totalCollateralValue = totalCollateralValue + (withdrawAmount * collateralPrice) - // Determine withdrawal amount based on remaining debt - var withdrawAmount: UFix64 = 0.0 + emit Withdrawn( + pid: pid, + poolUUID: self.uuid, + vaultType: collateralType, + amount: withdrawAmount, + withdrawnUUID: withdrawn.uuid + ) - if totalEffectiveDebt == 0.0 { - // No remaining debt - withdraw all collateral - // Round DOWN to ensure we never try to withdraw more than what's in the vault - // (UFix128→UFix64 conversion can introduce precision errors) - withdrawAmount = FlowALPMath.toUFix64RoundDown(positionView.trueBalance(ofToken: collateralType)) - } else { - // Remaining debt exists - calculate safe withdrawal maintaining target health - let position = self._borrowPosition(pid: pid) - let targetHealth = position.targetHealth - - // Calculate collateral needed to maintain target health: - // (collateralValue * collateralFactor) / (debtValue / borrowFactor) >= targetHealth - // collateralValue >= (targetHealth * debtValue) / (collateralFactor * borrowFactor) - // Debt is always MOET, so use MOET's borrow factor - let borrowFactor = self.borrowFactor[debtType] ?? 1.0 - - let minCollateralValue = UFix64(targetHealth) * UFix64(totalEffectiveDebt) / (collateralFactor * borrowFactor) - // Round UP to ensure protocol keeps enough collateral - let minCollateralAmountRaw = UFix128(minCollateralValue) / UFix128(collateralPrice) - let minCollateralAmount = FlowALPMath.toUFix64RoundUp(minCollateralAmountRaw) - - // Get total collateral - let totalCollateral = UFix64(positionView.trueBalance(ofToken: collateralType)) - - // Withdraw total minus minimum - if totalCollateral > minCollateralAmount { - withdrawAmount = totalCollateral - minCollateralAmount + collateralVaults.append(<- withdrawn) } else { - withdrawAmount = 0.0 - } - } - - // Perform direct withdrawal while holding lock (no health check needed for close) - var collateral: @{FungibleToken.Vault}? <- nil - - if withdrawAmount > 0.0 { - let position = self._borrowPosition(pid: pid) - let tokenState = self._borrowUpdatedTokenState(type: collateralType) - let reserveVault = (&self.reserves[collateralType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - - // Record withdrawal in position balance - if position.balances[collateralType] == nil { - position.balances[collateralType] = InternalBalance( - direction: BalanceDirection.Credit, - scaledBalance: 0.0 - ) + collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(collateralType)) } - position.balances[collateralType]!.recordWithdrawal( - amount: UFix128(withdrawAmount), - tokenState: tokenState - ) - - // Queue for update if necessary - self._queuePositionForUpdateIfNecessary(pid: pid) - - // Withdraw from reserves - collateral <-! reserveVault.withdraw(amount: withdrawAmount) - } else { - collateral <-! DeFiActionsUtils.getEmptyVault(collateralType) } - let finalCollateral <- collateral! - let finalCollateralAmount = finalCollateral.balance - // Emit event for position closure emit PositionClosed( pid: pid, poolUUID: self.uuid, - repaymentAmount: actualRepayment, - repaymentType: repaymentType, - collateralAmount: finalCollateralAmount, - collateralType: collateralType, + repaymentAmount: totalRepaymentValue, + repaymentType: repaymentVaultsLength > 0 ? Type<@{FungibleToken.Vault}>() : Type<@{FungibleToken.Vault}>(), + collateralAmount: totalCollateralValue, + collateralType: collateralTypes.length > 0 ? collateralTypes[0] : Type<@{FungibleToken.Vault}>(), finalDebt: totalEffectiveDebt ) - emit Withdrawn( - pid: pid, - poolUUID: self.uuid, - vaultType: collateralType, - amount: finalCollateralAmount, - withdrawnUUID: finalCollateral.uuid - ) - // Unlock position now that all operations are complete self._unlockPosition(pid) - return <-finalCollateral + return <-collateralVaults } /////////////////////// @@ -4134,32 +4070,42 @@ access(all) contract FlowALPv0 { return pool.getPositionDetails(pid: self.id).balances } - /// Returns the total debt amount and debt token type for this position. + /// Returns the total debt information for this position, grouped by token type. /// This is a convenience method for strategies to avoid recalculating debt from balances. /// - /// Note: Debt is always in MOET in this protocol. - /// Returns exact debt amount - no buffer needed since measurement and repayment happen + /// This method now supports multiple debt token types. It returns an array of DebtInfo, + /// one for each token type that has outstanding debt. + /// + /// Returns exact debt amounts - no buffer needed since measurement and repayment happen /// in the same transaction (no interest accrual between reads). /// - /// @return DebtInfo struct with exact debt amount and tokenType (always MOET). - access(all) fun getTotalDebt(): DebtInfo { + /// @return Array of DebtInfo structs, one per debt token type. Empty array if no debt. + access(all) fun getTotalDebt(): [DebtInfo] { let pool = self.pool.borrow()! let balances = pool.getPositionDetails(pid: self.id).balances - var totalDebtAmount: UFix64 = 0.0 + let debtsByType: {Type: UFix64} = {} + // Group debts by token type for balance in balances { if balance.direction == BalanceDirection.Debit { - // Accumulate debt amount - totalDebtAmount = totalDebtAmount + balance.balance + let tokenType = balance.vaultType + let currentDebt = debtsByType[tokenType] ?? 0.0 + debtsByType[tokenType] = currentDebt + balance.balance } } - // Debt is always MOET in this protocol + // Convert to array of DebtInfo + let debts: [DebtInfo] = [] + for tokenType in debtsByType.keys { + let amount = debtsByType[tokenType]! + debts.append(DebtInfo(amount: amount, tokenType: tokenType)) + } + // NOTE: Strategies using this must ensure their swap sources have sufficient // liquidity. SwapSource.minimumAvailable() may return slightly less than // actual debt due to source liquidity constraints or precision loss in // swap calculations. Strategies should handle this appropriately. - return DebtInfo(amount: totalDebtAmount, tokenType: Type<@MOET.Vault>()) + return debts } /// Returns the balance available for withdrawal of a given Vault type. If pullFromTopUpSource is true, the @@ -4282,19 +4228,18 @@ access(all) contract FlowALPv0 { /// /// See Pool.closePosition() for detailed implementation documentation. /// - /// @param repaymentVault: Vault containing funds to repay all debt (pass empty vault if no debt) - /// @param collateralType: Type of collateral to extract and return (e.g., FlowToken) - /// @return Vault containing all collateral including dust + /// Automatically detects and withdraws all collateral types in the position. + /// + /// @param repaymentVaults: Array of vaults containing funds to repay all debts (pass empty array if no debt) + /// @return Array of vaults containing all collateral (one vault per collateral type in the position) /// access(FungibleToken.Withdraw) fun closePosition( - repaymentVault: @{FungibleToken.Vault}, - collateralType: Type - ): @{FungibleToken.Vault} { + repaymentVaults: @[{FungibleToken.Vault}] + ): @[{FungibleToken.Vault}] { let pool = self.pool.borrow()! return <- pool.closePosition( pid: self.id, - repaymentVault: <-repaymentVault, - collateralType: collateralType + repaymentVaults: <-repaymentVaults ) } @@ -4654,9 +4599,10 @@ access(all) contract FlowALPv0 { /// This structure is NOT used internally. /// DebtInfo /// - /// A structure returned by getTotalDebt() to report the total debt and debt token type. + /// A structure returned by getTotalDebt() to report debt information for a specific token type. + /// getTotalDebt() returns an array of these, one per debt token type. access(all) struct DebtInfo { - /// The total amount of debt + /// The total amount of debt for this token type access(all) let amount: UFix64 /// The type of the debt token (nil if no debt) From cd7c16ce455c12ff1ec0a5b6b97a2fae6ae0cc3f Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:47:12 -0500 Subject: [PATCH 24/56] Apply suggestion from @nialexsan --- cadence/contracts/FlowALPv0.cdc | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 250179ae..024095a9 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3053,30 +3053,6 @@ access(all) contract FlowALPv0 { return <- withdrawn } - /// Helper function to process and consume repayment vaults array - /// Returns the total value deposited - access(self) fun _processRepaymentVaults(pid: UInt64, vaults: @[{FungibleToken.Vault}]): UFix64 { - var totalValue: UFix64 = 0.0 - let count = vaults.length - - var i = 0 - while i < count { - let vault <- vaults.remove(at: 0) - let balance = vault.balance - - if balance > 0.0 { - self._depositEffectsOnly(pid: pid, from: <-vault) - totalValue = totalValue + balance - } else { - destroy vault - } - i = i + 1 - } - - destroy vaults - return totalValue - } - /// Closes a position using the position's configured topUpSource for debt repayment. /// This is a convenience method that accesses the topUpSource directly. /// Closes a position by repaying all debts with pre-prepared vaults and returning all collateral. From f5d99da6f024ff843b1c59089198021e6cb5b2a2 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:54:20 -0500 Subject: [PATCH 25/56] fix event --- cadence/contracts/FlowALPv0.cdc | 55 +++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 024095a9..b789e3cc 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -59,14 +59,17 @@ access(all) contract FlowALPv0 { /// Emitted when a position is closed via the closePosition() method. /// This indicates a full position closure with debt repayment and collateral extraction. + /// + /// Note: repaymentTypes and collateralTypes are parallel arrays with their respective amounts. + /// For example: repaymentTypes[0] corresponds to repaymentAmounts[0]. access(all) event PositionClosed( pid: UInt64, poolUUID: UInt64, - repaymentAmount: UFix64, - repaymentType: Type, - collateralAmount: UFix64, - collateralType: Type, - finalDebt: UFix128 + repaymentAmounts: [UFix64], // Amounts repaid for each debt token + repaymentTypes: [String], // Type identifiers of repaid debt tokens + collateralAmounts: [UFix64], // Amounts withdrawn for each collateral token + collateralTypes: [String], // Type identifiers of withdrawn collateral tokens + finalDebt: UFix128 // Total effective debt remaining (should be ~0) ) access(all) event Rebalanced( @@ -3109,9 +3112,9 @@ access(all) contract FlowALPv0 { // Step 2: Lock the position for all state modifications self._lockPosition(pid) - // Step 3: Process repayment vaults inline + // Step 3: Process repayment vaults inline and track amounts by type var totalRepaymentValue: UFix64 = 0.0 - let repaymentVaultsLength = repaymentVaults.length + let repaymentsByType: {Type: UFix64} = {} // Consume all vaults from the array one by one while true { @@ -3120,7 +3123,13 @@ access(all) contract FlowALPv0 { } let vault <- repaymentVaults.removeLast() let balance = vault.balance + let vaultType = vault.getType() + if balance > 0.0 { + // Track repayment amount for this type + let currentAmount = repaymentsByType[vaultType] ?? 0.0 + repaymentsByType[vaultType] = currentAmount + balance + self._depositEffectsOnly(pid: pid, from: <-vault) totalRepaymentValue = totalRepaymentValue + balance } else { @@ -3151,9 +3160,10 @@ access(all) contract FlowALPv0 { } } - // Step 5: Withdraw all collateral types + // Step 5: Withdraw all collateral types and track amounts by type let positionView = self.buildPositionView(pid: pid) let collateralVaults: @[{FungibleToken.Vault}] <- [] + let collateralsByType: {Type: UFix64} = {} var totalCollateralValue: UFix64 = 0.0 for collateralType in collateralTypes { @@ -3197,6 +3207,9 @@ access(all) contract FlowALPv0 { let withdrawn <- reserveVault.withdraw(amount: withdrawAmount) totalCollateralValue = totalCollateralValue + (withdrawAmount * collateralPrice) + // Track collateral amount for this type + collateralsByType[collateralType] = withdrawAmount + emit Withdrawn( pid: pid, poolUUID: self.uuid, @@ -3207,18 +3220,34 @@ access(all) contract FlowALPv0 { collateralVaults.append(<- withdrawn) } else { + // Track zero withdrawal for this type + collateralsByType[collateralType] = 0.0 collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(collateralType)) } } - // Emit event for position closure + // Emit event for position closure with detailed breakdown + let repaymentAmounts: [UFix64] = [] + let repaymentTypeIds: [String] = [] + for repaymentType in repaymentsByType.keys { + repaymentAmounts.append(repaymentsByType[repaymentType]!) + repaymentTypeIds.append(repaymentType.identifier) + } + + let collateralAmounts: [UFix64] = [] + let collateralTypeIds: [String] = [] + for collateralType in collateralsByType.keys { + collateralAmounts.append(collateralsByType[collateralType]!) + collateralTypeIds.append(collateralType.identifier) + } + emit PositionClosed( pid: pid, poolUUID: self.uuid, - repaymentAmount: totalRepaymentValue, - repaymentType: repaymentVaultsLength > 0 ? Type<@{FungibleToken.Vault}>() : Type<@{FungibleToken.Vault}>(), - collateralAmount: totalCollateralValue, - collateralType: collateralTypes.length > 0 ? collateralTypes[0] : Type<@{FungibleToken.Vault}>(), + repaymentAmounts: repaymentAmounts, + repaymentTypes: repaymentTypeIds, + collateralAmounts: collateralAmounts, + collateralTypes: collateralTypeIds, finalDebt: totalEffectiveDebt ) From 182a5ff9e699957b996ff8b414beee43e24784e0 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:59:33 -0500 Subject: [PATCH 26/56] run ci/cd --- .github/workflows/cadence_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cadence_tests.yml b/.github/workflows/cadence_tests.yml index dd67efb5..8be59855 100644 --- a/.github/workflows/cadence_tests.yml +++ b/.github/workflows/cadence_tests.yml @@ -8,6 +8,7 @@ on: pull_request: branches: - main + - nialexsan/pre-refactor jobs: tests: From aed49a1be3f7b3f1933f11d7d3c84423a0592f05 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:32:13 -0500 Subject: [PATCH 27/56] fully repay debt --- cadence/contracts/FlowALPv0.cdc | 74 ++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index b789e3cc..8d614f9a 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3063,17 +3063,22 @@ access(all) contract FlowALPv0 { /// This is the ONLY close method - users must prepare repayment funds externally. /// This design eliminates circular dependencies and gives users full control over fund sourcing. /// + /// Overpayment Handling: + /// - If a repayment vault contains MORE than the debt amount, the excess is treated as a credit + /// - This overpayment credit is automatically withdrawn and returned to the user + /// - No epsilon tolerance: ALL debt must be exactly repaid (overpayment is okay, underpayment fails) + /// /// Steps: /// 1. Analyzes position to find all debt and collateral types (read-only, no lock) /// 2. Locks the position - /// 3. Deposits repayment vaults to eliminate debts - /// 4. Verifies debt is fully repaid (near-zero) - /// 5. Automatically withdraws ALL collateral types (including dust) - /// 6. Returns array of collateral vaults (one per collateral type found in position) + /// 3. Deposits repayment vaults to eliminate debts (overpayment flips debt to credit) + /// 4. Verifies NO debt remains (zero tolerance for unpaid debt) + /// 5. Automatically withdraws ALL collateral types + any overpayment dust + /// 6. Returns array of vaults (collateral + overpayment dust) /// /// @param pid: Position ID to close - /// @param repaymentVaults: Array of vaults containing funds to repay all debts (pass empty array if no debt) - /// @return Array of vaults containing all collateral (one vault per collateral type in the position) + /// @param repaymentVaults: Array of vaults containing funds to repay all debts (overpayment okay, underpayment fails) + /// @return Array of vaults containing all collateral + overpayment dust /// access(EPosition) fun closePosition( pid: UInt64, @@ -3102,10 +3107,9 @@ access(all) contract FlowALPv0 { let currentDebt = debtsByType[debtType] ?? 0.0 debtsByType[debtType] = currentDebt + balance.balance } else if balance.direction == BalanceDirection.Credit { - // Track collateral types (only if they have a balance) - if balance.balance > 0.0 { - collateralTypes.append(balance.vaultType) - } + // Track ALL collateral types present in position (including dust) + // Note: balance.balance may round to 0 but position might still have dust + collateralTypes.append(balance.vaultType) } } @@ -3126,6 +3130,12 @@ access(all) contract FlowALPv0 { let vaultType = vault.getType() if balance > 0.0 { + // CRITICAL: Validate repayment token is actually a debt token in this position + assert( + debtsByType.containsKey(vaultType), + message: "Repayment vault type \(vaultType.identifier) is not a debt token for this position" + ) + // Track repayment amount for this type let currentAmount = repaymentsByType[vaultType] ?? 0.0 repaymentsByType[vaultType] = currentAmount + balance @@ -3140,32 +3150,43 @@ access(all) contract FlowALPv0 { // Array is now empty destroy repaymentVaults - // Step 4: Verify debt is acceptably low (allow tolerance for overshoot scenarios) + // Step 4: Verify ALL debt is EXACTLY repaid (no epsilon tolerance) + // If overpaid, debt flips to credit and we'll return it as dust let updatedDetails = self.getPositionDetails(pid: pid) var totalEffectiveDebt: UFix128 = 0.0 + let overpaymentTypes: [Type] = [] // Track tokens that were overpaid (flipped to credit) + // CRITICAL: No debt tokens should remain in debit for balance in updatedDetails.balances { if balance.direction == BalanceDirection.Debit { - let price = self.priceOracle.price(ofToken: balance.vaultType) - ?? panic("Price not available for token \(balance.vaultType.identifier)") - let borrowFactor = self.borrowFactor[balance.vaultType] - ?? panic("Borrow factor not found for token \(balance.vaultType.identifier)") - - let effectiveDebt = FlowALPv0.effectiveDebt( - debit: UFix128(balance.balance), - price: UFix128(price), - borrowFactor: UFix128(borrowFactor) + // ZERO tolerance - all debt must be fully repaid + assert( + false, + message: "Debt not fully repaid for \(balance.vaultType.identifier): \(balance.balance) remaining. Position cannot be closed with outstanding debt." ) - totalEffectiveDebt = totalEffectiveDebt + effectiveDebt + } + + // Check if this was a debt token that got overpaid (now showing as credit) + if debtsByType.containsKey(balance.vaultType) && balance.direction == BalanceDirection.Credit { + // This token was originally debt but is now credit due to overpayment + // We'll return this dust to the user + overpaymentTypes.append(balance.vaultType) } } - // Step 5: Withdraw all collateral types and track amounts by type + // Step 5: Withdraw all collateral types + overpayment dust and track amounts by type let positionView = self.buildPositionView(pid: pid) let collateralVaults: @[{FungibleToken.Vault}] <- [] let collateralsByType: {Type: UFix64} = {} var totalCollateralValue: UFix64 = 0.0 + // Add overpayment types to withdrawal list (debt tokens that flipped to credit) + for overpaymentType in overpaymentTypes { + if !collateralTypes.contains(overpaymentType) { + collateralTypes.append(overpaymentType) + } + } + for collateralType in collateralTypes { let collateralBalance = positionView.trueBalance(ofToken: collateralType) @@ -3227,6 +3248,10 @@ access(all) contract FlowALPv0 { } // Emit event for position closure with detailed breakdown + // Build arrays from dictionaries + // Note: For true determinism, we use collateralTypes/debt detection order + // (insertion order is preserved in our earlier loops) + let repaymentAmounts: [UFix64] = [] let repaymentTypeIds: [String] = [] for repaymentType in repaymentsByType.keys { @@ -4234,9 +4259,10 @@ access(all) contract FlowALPv0 { /// See Pool.closePosition() for detailed implementation documentation. /// /// Automatically detects and withdraws all collateral types in the position. + /// If repayment vaults contain overpayment, the excess is returned as dust. /// - /// @param repaymentVaults: Array of vaults containing funds to repay all debts (pass empty array if no debt) - /// @return Array of vaults containing all collateral (one vault per collateral type in the position) + /// @param repaymentVaults: Array of vaults containing funds to repay all debts (overpayment okay, underpayment fails) + /// @return Array of vaults containing all collateral + any overpayment dust /// access(FungibleToken.Withdraw) fun closePosition( repaymentVaults: @[{FungibleToken.Vault}] From 355e1c636d53e3c1556682ba44ba9eb4e81097f3 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:45:05 -0500 Subject: [PATCH 28/56] tweaks --- cadence/contracts/FlowALPv0.cdc | 114 ++++++++++++++------------------ 1 file changed, 48 insertions(+), 66 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 8d614f9a..2be0f2d9 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -60,16 +60,13 @@ access(all) contract FlowALPv0 { /// Emitted when a position is closed via the closePosition() method. /// This indicates a full position closure with debt repayment and collateral extraction. /// - /// Note: repaymentTypes and collateralTypes are parallel arrays with their respective amounts. - /// For example: repaymentTypes[0] corresponds to repaymentAmounts[0]. + /// Uses dictionaries instead of parallel arrays for deterministic, unambiguous data. + /// Keys are token type identifiers (e.g., "A.xxx.FlowToken.Vault"). access(all) event PositionClosed( pid: UInt64, poolUUID: UInt64, - repaymentAmounts: [UFix64], // Amounts repaid for each debt token - repaymentTypes: [String], // Type identifiers of repaid debt tokens - collateralAmounts: [UFix64], // Amounts withdrawn for each collateral token - collateralTypes: [String], // Type identifiers of withdrawn collateral tokens - finalDebt: UFix128 // Total effective debt remaining (should be ~0) + repaymentsByType: {String: UFix64}, // Map of debt token type → amount repaid + withdrawalsByType: {String: UFix64} // Map of token type → amount withdrawn (collateral + overpayment dust) ) access(all) event Rebalanced( @@ -3116,9 +3113,10 @@ access(all) contract FlowALPv0 { // Step 2: Lock the position for all state modifications self._lockPosition(pid) - // Step 3: Process repayment vaults inline and track amounts by type + // Step 3: Process repayment vaults and compute overpayment directly var totalRepaymentValue: UFix64 = 0.0 let repaymentsByType: {Type: UFix64} = {} + let overpaymentsByType: {Type: UFix64} = {} // Track overpayment per token (repaid - owed) // Consume all vaults from the array one by one while true { @@ -3138,7 +3136,14 @@ access(all) contract FlowALPv0 { // Track repayment amount for this type let currentAmount = repaymentsByType[vaultType] ?? 0.0 - repaymentsByType[vaultType] = currentAmount + balance + let totalRepaid = currentAmount + balance + repaymentsByType[vaultType] = totalRepaid + + // Compute overpayment for this type + let debtOwed = debtsByType[vaultType]! + if totalRepaid > debtOwed { + overpaymentsByType[vaultType] = totalRepaid - debtOwed + } self._depositEffectsOnly(pid: pid, from: <-vault) totalRepaymentValue = totalRepaymentValue + balance @@ -3151,10 +3156,7 @@ access(all) contract FlowALPv0 { destroy repaymentVaults // Step 4: Verify ALL debt is EXACTLY repaid (no epsilon tolerance) - // If overpaid, debt flips to credit and we'll return it as dust let updatedDetails = self.getPositionDetails(pid: pid) - var totalEffectiveDebt: UFix128 = 0.0 - let overpaymentTypes: [Type] = [] // Track tokens that were overpaid (flipped to credit) // CRITICAL: No debt tokens should remain in debit for balance in updatedDetails.balances { @@ -3165,58 +3167,49 @@ access(all) contract FlowALPv0 { message: "Debt not fully repaid for \(balance.vaultType.identifier): \(balance.balance) remaining. Position cannot be closed with outstanding debt." ) } - - // Check if this was a debt token that got overpaid (now showing as credit) - if debtsByType.containsKey(balance.vaultType) && balance.direction == BalanceDirection.Credit { - // This token was originally debt but is now credit due to overpayment - // We'll return this dust to the user - overpaymentTypes.append(balance.vaultType) - } } // Step 5: Withdraw all collateral types + overpayment dust and track amounts by type let positionView = self.buildPositionView(pid: pid) let collateralVaults: @[{FungibleToken.Vault}] <- [] - let collateralsByType: {Type: UFix64} = {} - var totalCollateralValue: UFix64 = 0.0 + let withdrawalsByType: {Type: UFix64} = {} // Track all withdrawals (collateral + overpayment) - // Add overpayment types to withdrawal list (debt tokens that flipped to credit) - for overpaymentType in overpaymentTypes { - if !collateralTypes.contains(overpaymentType) { - collateralTypes.append(overpaymentType) - } + // Build deduplicated withdrawal types list (collateral + overpayment) + let withdrawalTypes: {Type: Bool} = {} // Use as set for deduplication + for collateralType in collateralTypes { + withdrawalTypes[collateralType] = true + } + for overpaymentType in overpaymentsByType.keys { + withdrawalTypes[overpaymentType] = true } - for collateralType in collateralTypes { - let collateralBalance = positionView.trueBalance(ofToken: collateralType) + for withdrawalType in withdrawalTypes.keys { + let tokenBalance = positionView.trueBalance(ofToken: withdrawalType) - if collateralBalance == 0.0 { - // No balance for this collateral type - return empty vault - collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(collateralType)) + if tokenBalance == 0.0 { + // No balance for this type - return empty vault + collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(withdrawalType)) + withdrawalsByType[withdrawalType] = 0.0 continue } - // Calculate collateral price and factor - let collateralPrice = self.priceOracle.price(ofToken: collateralType) - ?? panic("Price not available for collateral \(collateralType.identifier)") - - // Determine withdrawal amount - withdraw all collateral for this type - let withdrawAmount = FlowALPMath.toUFix64RoundDown(collateralBalance) + // Determine withdrawal amount - withdraw all balance for this type + let withdrawAmount = FlowALPMath.toUFix64RoundDown(tokenBalance) // Perform direct withdrawal while holding lock if withdrawAmount > 0.0 { let position = self._borrowPosition(pid: pid) - let tokenState = self._borrowUpdatedTokenState(type: collateralType) - let reserveVault = (&self.reserves[collateralType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! + let tokenState = self._borrowUpdatedTokenState(type: withdrawalType) + let reserveVault = (&self.reserves[withdrawalType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! // Record withdrawal in position balance - if position.balances[collateralType] == nil { - position.balances[collateralType] = InternalBalance( + if position.balances[withdrawalType] == nil { + position.balances[withdrawalType] = InternalBalance( direction: BalanceDirection.Credit, scaledBalance: 0.0 ) } - position.balances[collateralType]!.recordWithdrawal( + position.balances[withdrawalType]!.recordWithdrawal( amount: UFix128(withdrawAmount), tokenState: tokenState ) @@ -3226,15 +3219,14 @@ access(all) contract FlowALPv0 { // Withdraw from reserves let withdrawn <- reserveVault.withdraw(amount: withdrawAmount) - totalCollateralValue = totalCollateralValue + (withdrawAmount * collateralPrice) - // Track collateral amount for this type - collateralsByType[collateralType] = withdrawAmount + // Track withdrawal amount for this type + withdrawalsByType[withdrawalType] = withdrawAmount emit Withdrawn( pid: pid, poolUUID: self.uuid, - vaultType: collateralType, + vaultType: withdrawalType, amount: withdrawAmount, withdrawnUUID: withdrawn.uuid ) @@ -3242,38 +3234,28 @@ access(all) contract FlowALPv0 { collateralVaults.append(<- withdrawn) } else { // Track zero withdrawal for this type - collateralsByType[collateralType] = 0.0 - collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(collateralType)) + withdrawalsByType[withdrawalType] = 0.0 + collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(withdrawalType)) } } // Emit event for position closure with detailed breakdown - // Build arrays from dictionaries - // Note: For true determinism, we use collateralTypes/debt detection order - // (insertion order is preserved in our earlier loops) - - let repaymentAmounts: [UFix64] = [] - let repaymentTypeIds: [String] = [] + // Convert Type keys to String identifiers for event (dictionaries are deterministic) + let repaymentsEvent: {String: UFix64} = {} for repaymentType in repaymentsByType.keys { - repaymentAmounts.append(repaymentsByType[repaymentType]!) - repaymentTypeIds.append(repaymentType.identifier) + repaymentsEvent[repaymentType.identifier] = repaymentsByType[repaymentType]! } - let collateralAmounts: [UFix64] = [] - let collateralTypeIds: [String] = [] - for collateralType in collateralsByType.keys { - collateralAmounts.append(collateralsByType[collateralType]!) - collateralTypeIds.append(collateralType.identifier) + let withdrawalsEvent: {String: UFix64} = {} + for withdrawalType in withdrawalsByType.keys { + withdrawalsEvent[withdrawalType.identifier] = withdrawalsByType[withdrawalType]! } emit PositionClosed( pid: pid, poolUUID: self.uuid, - repaymentAmounts: repaymentAmounts, - repaymentTypes: repaymentTypeIds, - collateralAmounts: collateralAmounts, - collateralTypes: collateralTypeIds, - finalDebt: totalEffectiveDebt + repaymentsByType: repaymentsEvent, + withdrawalsByType: withdrawalsEvent ) // Unlock position now that all operations are complete From d9f0b6ceb52bdd448945f4d2677ddadeb86c24e0 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:16:28 -0500 Subject: [PATCH 29/56] tweaks --- cadence/contracts/FlowALPv0.cdc | 56 ++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 2be0f2d9..b17784ad 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3060,10 +3060,11 @@ access(all) contract FlowALPv0 { /// This is the ONLY close method - users must prepare repayment funds externally. /// This design eliminates circular dependencies and gives users full control over fund sourcing. /// - /// Overpayment Handling: - /// - If a repayment vault contains MORE than the debt amount, the excess is treated as a credit - /// - This overpayment credit is automatically withdrawn and returned to the user - /// - No epsilon tolerance: ALL debt must be exactly repaid (overpayment is okay, underpayment fails) + /// Overpayment Handling (Strict): + /// - Overpayment becomes a credit balance via _depositEffectsOnly + /// - Close withdraws ONLY the exact overpayment amount (not any pre-existing credits of debt token types) + /// - This prevents accidentally withdrawing unintended credits in debt token types + /// - No epsilon tolerance: ALL debt must be exactly repaid (overpayment okay, underpayment fails) /// /// Steps: /// 1. Analyzes position to find all debt and collateral types (read-only, no lock) @@ -3114,9 +3115,8 @@ access(all) contract FlowALPv0 { self._lockPosition(pid) // Step 3: Process repayment vaults and compute overpayment directly - var totalRepaymentValue: UFix64 = 0.0 let repaymentsByType: {Type: UFix64} = {} - let overpaymentsByType: {Type: UFix64} = {} // Track overpayment per token (repaid - owed) + let overpaymentsByType: {Type: UFix64} = {} // Track EXACT overpayment per token (repaid - owed) // Consume all vaults from the array one by one while true { @@ -3139,14 +3139,13 @@ access(all) contract FlowALPv0 { let totalRepaid = currentAmount + balance repaymentsByType[vaultType] = totalRepaid - // Compute overpayment for this type + // Compute EXACT overpayment for this type let debtOwed = debtsByType[vaultType]! if totalRepaid > debtOwed { overpaymentsByType[vaultType] = totalRepaid - debtOwed } self._depositEffectsOnly(pid: pid, from: <-vault) - totalRepaymentValue = totalRepaymentValue + balance } else { destroy vault } @@ -3169,33 +3168,46 @@ access(all) contract FlowALPv0 { } } - // Step 5: Withdraw all collateral types + overpayment dust and track amounts by type + // Step 5: Withdraw all collateral + capped overpayment dust (deterministic order) let positionView = self.buildPositionView(pid: pid) let collateralVaults: @[{FungibleToken.Vault}] <- [] let withdrawalsByType: {Type: UFix64} = {} // Track all withdrawals (collateral + overpayment) - // Build deduplicated withdrawal types list (collateral + overpayment) - let withdrawalTypes: {Type: Bool} = {} // Use as set for deduplication + // Build ORDERED, deduplicated withdrawal list: + // 1. Collateral types first (from position analysis) + // 2. Overpayment types second (if not already in collateral list) + let orderedWithdrawalTypes: [Type] = [] + let seen: {Type: Bool} = {} + for collateralType in collateralTypes { - withdrawalTypes[collateralType] = true + if seen[collateralType] == nil { + orderedWithdrawalTypes.append(collateralType) + seen[collateralType] = true + } } for overpaymentType in overpaymentsByType.keys { - withdrawalTypes[overpaymentType] = true + if seen[overpaymentType] == nil { + orderedWithdrawalTypes.append(overpaymentType) + seen[overpaymentType] = true + } } - for withdrawalType in withdrawalTypes.keys { + // Withdraw each type in deterministic order + for withdrawalType in orderedWithdrawalTypes { let tokenBalance = positionView.trueBalance(ofToken: withdrawalType) - if tokenBalance == 0.0 { - // No balance for this type - return empty vault - collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(withdrawalType)) - withdrawalsByType[withdrawalType] = 0.0 - continue + // Determine withdrawal amount: + // - For overpayment types: withdraw ONLY the overpayment amount (capped) + // - For collateral types: withdraw full balance + var withdrawAmount: UFix64 = 0.0 + if overpaymentsByType.containsKey(withdrawalType) { + // CAP to overpayment amount (don't withdraw pre-existing credits) + withdrawAmount = overpaymentsByType[withdrawalType]! + } else if tokenBalance > 0.0 { + // Full collateral withdrawal + withdrawAmount = FlowALPMath.toUFix64RoundDown(tokenBalance) } - // Determine withdrawal amount - withdraw all balance for this type - let withdrawAmount = FlowALPMath.toUFix64RoundDown(tokenBalance) - // Perform direct withdrawal while holding lock if withdrawAmount > 0.0 { let position = self._borrowPosition(pid: pid) From 6c722af4861300221c44c94fe58338109a4d08c2 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:26:43 -0500 Subject: [PATCH 30/56] tweaks --- cadence/contracts/FlowALPv0.cdc | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index b17784ad..14d714a0 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3168,6 +3168,21 @@ access(all) contract FlowALPv0 { } } + // CRITICAL: Verify all original debt types were covered (paranoid check) + // This ensures no debt type "disappeared" from balance view without being repaid + for debtType in debtsByType.keys { + var foundAsNonDebit = false + for balance in updatedDetails.balances { + if balance.vaultType == debtType && balance.direction != BalanceDirection.Debit { + foundAsNonDebit = true + break + } + } + // If debt type is not in balances at all, that's also fine (fully repaid to zero) + // But if it's still there as Debit, we'd have caught it above + // This check is mostly for auditor confidence + } + // Step 5: Withdraw all collateral + capped overpayment dust (deterministic order) let positionView = self.buildPositionView(pid: pid) let collateralVaults: @[{FungibleToken.Vault}] <- [] @@ -3195,17 +3210,20 @@ access(all) contract FlowALPv0 { // Withdraw each type in deterministic order for withdrawalType in orderedWithdrawalTypes { let tokenBalance = positionView.trueBalance(ofToken: withdrawalType) + let withdrawable = FlowALPMath.toUFix64RoundDown(tokenBalance) // Determine withdrawal amount: - // - For overpayment types: withdraw ONLY the overpayment amount (capped) + // - For overpayment types: withdraw ONLY the overpayment amount (capped to actual balance) // - For collateral types: withdraw full balance var withdrawAmount: UFix64 = 0.0 if overpaymentsByType.containsKey(withdrawalType) { - // CAP to overpayment amount (don't withdraw pre-existing credits) - withdrawAmount = overpaymentsByType[withdrawalType]! - } else if tokenBalance > 0.0 { + // CAP to min(overpayment, actual withdrawable balance) + // This handles rounding differences between external balance view and internal scaled balances + let overpaymentAmount = overpaymentsByType[withdrawalType]! + withdrawAmount = overpaymentAmount < withdrawable ? overpaymentAmount : withdrawable + } else if withdrawable > 0.0 { // Full collateral withdrawal - withdrawAmount = FlowALPMath.toUFix64RoundDown(tokenBalance) + withdrawAmount = withdrawable } // Perform direct withdrawal while holding lock From 1b42f8a531931e09524489b062087c246daf9baf Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:38:55 -0500 Subject: [PATCH 31/56] tweaks --- cadence/contracts/FlowALPv0.cdc | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 14d714a0..a099acf1 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3062,8 +3062,9 @@ access(all) contract FlowALPv0 { /// /// Overpayment Handling (Strict): /// - Overpayment becomes a credit balance via _depositEffectsOnly - /// - Close withdraws ONLY the exact overpayment amount (not any pre-existing credits of debt token types) - /// - This prevents accidentally withdrawing unintended credits in debt token types + /// - Close withdraws UP TO the computed overpayment amount (capped by actual withdrawable credit) + /// - Capping handles rounding differences between external balance view and internal scaled balances + /// - This prevents accidentally withdrawing unintended pre-existing credits in debt token types /// - No epsilon tolerance: ALL debt must be exactly repaid (overpayment okay, underpayment fails) /// /// Steps: @@ -3157,7 +3158,7 @@ access(all) contract FlowALPv0 { // Step 4: Verify ALL debt is EXACTLY repaid (no epsilon tolerance) let updatedDetails = self.getPositionDetails(pid: pid) - // CRITICAL: No debt tokens should remain in debit + // CRITICAL: No debt tokens should remain in debit (zero tolerance) for balance in updatedDetails.balances { if balance.direction == BalanceDirection.Debit { // ZERO tolerance - all debt must be fully repaid @@ -3168,21 +3169,6 @@ access(all) contract FlowALPv0 { } } - // CRITICAL: Verify all original debt types were covered (paranoid check) - // This ensures no debt type "disappeared" from balance view without being repaid - for debtType in debtsByType.keys { - var foundAsNonDebit = false - for balance in updatedDetails.balances { - if balance.vaultType == debtType && balance.direction != BalanceDirection.Debit { - foundAsNonDebit = true - break - } - } - // If debt type is not in balances at all, that's also fine (fully repaid to zero) - // But if it's still there as Debit, we'd have caught it above - // This check is mostly for auditor confidence - } - // Step 5: Withdraw all collateral + capped overpayment dust (deterministic order) let positionView = self.buildPositionView(pid: pid) let collateralVaults: @[{FungibleToken.Vault}] <- [] @@ -3213,12 +3199,12 @@ access(all) contract FlowALPv0 { let withdrawable = FlowALPMath.toUFix64RoundDown(tokenBalance) // Determine withdrawal amount: - // - For overpayment types: withdraw ONLY the overpayment amount (capped to actual balance) + // - For overpayment types: withdraw UP TO overpayment amount (capped to actual balance) // - For collateral types: withdraw full balance var withdrawAmount: UFix64 = 0.0 if overpaymentsByType.containsKey(withdrawalType) { - // CAP to min(overpayment, actual withdrawable balance) - // This handles rounding differences between external balance view and internal scaled balances + // Withdraw min(computed overpayment, actual withdrawable balance) + // Handles rounding differences between external balance view and internal scaled balances let overpaymentAmount = overpaymentsByType[withdrawalType]! withdrawAmount = overpaymentAmount < withdrawable ? overpaymentAmount : withdrawable } else if withdrawable > 0.0 { From f2b7859bb5dbb1494eb5e0d9d04d1ec28adf30e7 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:32:27 -0500 Subject: [PATCH 32/56] fix position direction --- cadence/contracts/FlowALPv0.cdc | 31 +++++--- .../position/repay_and_close_position.cdc | 74 ++++++++++++------- 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index a099acf1..6de967b8 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -323,10 +323,16 @@ access(all) contract FlowALPv0 { // so we just decrement the debt. let updatedBalance = trueBalance - amount - self.scaledBalance = FlowALPv0.trueBalanceToScaledBalance( - updatedBalance, - interestIndex: tokenState.debitInterestIndex - ) + // If debt is fully repaid (updatedBalance == 0), flip to credit + if updatedBalance == 0.0 { + self.direction = BalanceDirection.Credit + self.scaledBalance = 0.0 + } else { + self.scaledBalance = FlowALPv0.trueBalanceToScaledBalance( + updatedBalance, + interestIndex: tokenState.debitInterestIndex + ) + } // Decrease the total debit balance for the token tokenState.decreaseDebitBalance(by: amount) @@ -3083,19 +3089,22 @@ access(all) contract FlowALPv0 { pid: UInt64, repaymentVaults: @[{FungibleToken.Vault}] ): @[{FungibleToken.Vault}] { + pre { + !self.isPausedOrWarmup(): "Operations are paused by governance" + self.positions[pid] != nil: "Invalid position ID" + } post { self.positionLock[pid] == nil: "Position is not unlocked" } - // Manual validation (replacing pre conditions to avoid resource handling issues) - assert(!self.isPausedOrWarmup(), message: "Operations are paused by governance") - assert(self.positions[pid] != nil, message: "Invalid position ID") - if self.debugLogging { log(" [CONTRACT] closePosition(pid: \(pid), repaymentVaults: \(repaymentVaults.length))") } - // Step 1: Analyze position to find all debt and collateral types + // Step 1: Lock the position for all state modifications + self._lockPosition(pid) + + // Step 2: Analyze position to find all debt and collateral types let positionDetails = self.getPositionDetails(pid: pid) let debtsByType: {Type: UFix64} = {} let collateralTypes: [Type] = [] @@ -3112,9 +3121,6 @@ access(all) contract FlowALPv0 { } } - // Step 2: Lock the position for all state modifications - self._lockPosition(pid) - // Step 3: Process repayment vaults and compute overpayment directly let repaymentsByType: {Type: UFix64} = {} let overpaymentsByType: {Type: UFix64} = {} // Track EXACT overpayment per token (repaid - owed) @@ -3162,6 +3168,7 @@ access(all) contract FlowALPv0 { for balance in updatedDetails.balances { if balance.direction == BalanceDirection.Debit { // ZERO tolerance - all debt must be fully repaid + // Since getTotalDebt rounds UP, this should never fail with proper repayment assert( false, message: "Debt not fully repaid for \(balance.vaultType.identifier): \(balance.balance) remaining. Position cannot be closed with outstanding debt." diff --git a/cadence/transactions/flow-alp/position/repay_and_close_position.cdc b/cadence/transactions/flow-alp/position/repay_and_close_position.cdc index 32c8b9c4..4404829e 100644 --- a/cadence/transactions/flow-alp/position/repay_and_close_position.cdc +++ b/cadence/transactions/flow-alp/position/repay_and_close_position.cdc @@ -1,13 +1,13 @@ -// Repay MOET debt and withdraw collateral from a position +// Repay MOET debt and close position, withdrawing all collateral // -// This transaction uses withdrawAndPull with pullFromTopUpSource: true to: -// 1. Automatically pull MOET from the user's vault to repay the debt -// 2. Withdraw and return the collateral to the user +// This transaction uses the closePosition method to: +// 1. Repay all debt with provided MOET vault +// 2. Withdraw and return all collateral to the user // // After running this transaction: // - MOET debt will be repaid (balance goes to 0) -// - Flow collateral will be returned to the user's vault -// - The position will be empty (all balances at 0) +// - All collateral will be returned to the user's vault +// - The position will be closed import "FungibleToken" import "FlowToken" @@ -18,7 +18,8 @@ import "MOET" transaction(positionId: UInt64) { let position: auth(FungibleToken.Withdraw) &FlowALPv0.Position - let receiverRef: &{FungibleToken.Receiver} + let flowReceiverRef: &{FungibleToken.Receiver} + let moetReceiverRef: &{FungibleToken.Receiver} let moetWithdrawRef: auth(FungibleToken.Withdraw) &{FungibleToken.Vault} prepare(borrower: auth(BorrowValue) &Account) { @@ -30,10 +31,14 @@ transaction(positionId: UInt64) { // Borrow the position with withdraw entitlement self.position = manager.borrowAuthorizedPosition(pid: positionId) as! auth(FungibleToken.Withdraw) &FlowALPv0.Position - // Get receiver reference for depositing withdrawn collateral - self.receiverRef = borrower.capabilities.borrow<&{FungibleToken.Receiver}>( + // Get receiver references for depositing withdrawn collateral and overpayment + self.flowReceiverRef = borrower.capabilities.borrow<&{FungibleToken.Receiver}>( /public/flowTokenReceiver - ) ?? panic("Could not borrow receiver reference to the recipient's Vault") + ) ?? panic("Could not borrow Flow receiver reference") + + self.moetReceiverRef = borrower.capabilities.borrow<&{FungibleToken.Receiver}>( + MOET.VaultPublicPath + ) ?? panic("Could not borrow MOET receiver reference") // Borrow withdraw reference to borrower's MOET vault to repay debt self.moetWithdrawRef = borrower.storage.borrow(from: MOET.VaultStoragePath) @@ -41,24 +46,41 @@ transaction(positionId: UInt64) { } execute { - // Repay all MOET debt without requiring EParticipant: use a Sink and depositCapacity - if self.moetWithdrawRef.balance > 0.0 { - let sink: {DeFiActions.Sink} = self.position.createSink(type: Type<@MOET.Vault>()) - sink.depositCapacity(from: self.moetWithdrawRef) + // Calculate exact MOET debt from position + let debts = self.position.getTotalDebt() + var moetDebt: UFix64 = 0.0 + for debt in debts { + if debt.tokenType == Type<@MOET.Vault>() { + moetDebt = debt.amount + break + } + } + + // Withdraw exact MOET debt amount (rounded up by getTotalDebt) + // No buffer needed - contract now properly flips to credit when debt == 0 + let repaymentVaults: @[{FungibleToken.Vault}] <- [] + if moetDebt > 0.0 { + repaymentVaults.append(<- self.moetWithdrawRef.withdraw(amount: moetDebt)) } - // Now withdraw all available Flow collateral without top-up assistance - let withdrawAmount = self.position.availableBalance( - type: Type<@FlowToken.Vault>(), - pullFromTopUpSource: false - ) - let withdrawnVault <- self.position.withdrawAndPull( - type: Type<@FlowToken.Vault>(), - amount: withdrawAmount, - pullFromTopUpSource: false - ) + // Close position: repay debt and withdraw all collateral in one call + // Any overpayment will be returned along with collateral + let returnedVaults <- self.position.closePosition(repaymentVaults: <-repaymentVaults) - // Deposit withdrawn collateral to user's vault - self.receiverRef.deposit(from: <-withdrawnVault) + // Deposit all returned collateral and overpayment to appropriate vaults + while returnedVaults.length > 0 { + let vault <- returnedVaults.removeFirst() + let vaultType = vault.getType() + + // Route to appropriate receiver based on token type + if vaultType == Type<@FlowToken.Vault>() { + self.flowReceiverRef.deposit(from: <-vault) + } else if vaultType == Type<@MOET.Vault>() { + self.moetReceiverRef.deposit(from: <-vault) + } else { + panic("Unexpected vault type returned: \(vaultType.identifier)") + } + } + destroy returnedVaults } } From c008602354243ec3db3b768636f98e870500cefa Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:09:29 -0500 Subject: [PATCH 33/56] address PR comments --- cadence/contracts/FlowALPv0.cdc | 262 ++++++++---------- .../position/repay_and_close_position.cdc | 62 ++--- 2 files changed, 140 insertions(+), 184 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 6de967b8..1a610c93 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3074,20 +3074,21 @@ access(all) contract FlowALPv0 { /// - No epsilon tolerance: ALL debt must be exactly repaid (overpayment okay, underpayment fails) /// /// Steps: - /// 1. Analyzes position to find all debt and collateral types (read-only, no lock) - /// 2. Locks the position - /// 3. Deposits repayment vaults to eliminate debts (overpayment flips debt to credit) + /// 1. Locks the position + /// 2. Analyzes position to find all debt and collateral types + /// 3. Pulls from sources to repay debts (any overpayment becomes credit balance) /// 4. Verifies NO debt remains (zero tolerance for unpaid debt) - /// 5. Automatically withdraws ALL collateral types + any overpayment dust - /// 6. Returns array of vaults (collateral + overpayment dust) + /// 5. Automatically withdraws ALL collateral + any credit balances + /// 6. Returns array of vaults (collateral + any excess funds) /// /// @param pid: Position ID to close - /// @param repaymentVaults: Array of vaults containing funds to repay all debts (overpayment okay, underpayment fails) + /// @param repaymentSources: Array of Sources that can provide funds to repay debts + /// Sources are pulled from as needed (supports swapping, multi-vault, etc.) /// @return Array of vaults containing all collateral + overpayment dust /// access(EPosition) fun closePosition( pid: UInt64, - repaymentVaults: @[{FungibleToken.Vault}] + repaymentSources: [{DeFiActions.Source}] ): @[{FungibleToken.Vault}] { pre { !self.isPausedOrWarmup(): "Operations are paused by governance" @@ -3098,7 +3099,7 @@ access(all) contract FlowALPv0 { } if self.debugLogging { - log(" [CONTRACT] closePosition(pid: \(pid), repaymentVaults: \(repaymentVaults.length))") + log(" [CONTRACT] closePosition(pid: \(pid), repaymentSources: \(repaymentSources.length))") } // Step 1: Lock the position for all state modifications @@ -3112,54 +3113,61 @@ access(all) contract FlowALPv0 { for balance in positionDetails.balances { if balance.direction == BalanceDirection.Debit { let debtType = balance.vaultType - let currentDebt = debtsByType[debtType] ?? 0.0 - debtsByType[debtType] = currentDebt + balance.balance + // Sanity check: each position should have at most one balance entry per token type + assert( + debtsByType[debtType] == nil, + message: "Sanity check failed: found multiple balances for \(debtType.identifier) while closing position" + ) + debtsByType[debtType] = balance.balance } else if balance.direction == BalanceDirection.Credit { // Track ALL collateral types present in position (including dust) // Note: balance.balance may round to 0 but position might still have dust + // Sanity check: each position should have at most one balance entry per token type + assert( + !collateralTypes.contains(balance.vaultType), + message: "Sanity check failed: found multiple balances for \(balance.vaultType.identifier) while closing position" + ) collateralTypes.append(balance.vaultType) } } - // Step 3: Process repayment vaults and compute overpayment directly - let repaymentsByType: {Type: UFix64} = {} - let overpaymentsByType: {Type: UFix64} = {} // Track EXACT overpayment per token (repaid - owed) + // Step 3: Pull repayment from sources + // Note: Any overpayment naturally becomes a credit balance and is withdrawn in Step 5 - // Consume all vaults from the array one by one - while true { - if repaymentVaults.length == 0 { - break - } - let vault <- repaymentVaults.removeLast() - let balance = vault.balance - let vaultType = vault.getType() + // For each debt type, try to pull from sources + for debtType in debtsByType.keys { + let debtAmount = debtsByType[debtType]! + var remainingDebt = debtAmount - if balance > 0.0 { - // CRITICAL: Validate repayment token is actually a debt token in this position - assert( - debtsByType.containsKey(vaultType), - message: "Repayment vault type \(vaultType.identifier) is not a debt token for this position" - ) + // Try each source until debt is fully paid + for source in repaymentSources { + if remainingDebt == 0.0 { + break + } - // Track repayment amount for this type - let currentAmount = repaymentsByType[vaultType] ?? 0.0 - let totalRepaid = currentAmount + balance - repaymentsByType[vaultType] = totalRepaid + // Only pull from sources that provide the debt type we need + if source.getSourceType() == debtType { + // Pull up to remaining debt amount + let pulled <- source.withdrawAvailable(maxAmount: remainingDebt) + let pulledAmount = pulled.balance - // Compute EXACT overpayment for this type - let debtOwed = debtsByType[vaultType]! - if totalRepaid > debtOwed { - overpaymentsByType[vaultType] = totalRepaid - debtOwed - } + if pulledAmount > 0.0 { + remainingDebt = remainingDebt - pulledAmount - self._depositEffectsOnly(pid: pid, from: <-vault) - } else { - destroy vault + // Deposit to position (any overpayment flips to credit) + self._depositEffectsOnly(pid: pid, from: <-pulled) + } else { + destroy pulled + } + } } - } - // Array is now empty - destroy repaymentVaults + // Verify we got enough for this debt type + assert( + remainingDebt == 0.0, + message: "Insufficient funds from sources for \(debtType.identifier) debt: needed \(debtAmount), got \(debtAmount - remainingDebt)" + ) + } // Step 4: Verify ALL debt is EXACTLY repaid (no epsilon tolerance) let updatedDetails = self.getPositionDetails(pid: pid) @@ -3169,21 +3177,17 @@ access(all) contract FlowALPv0 { if balance.direction == BalanceDirection.Debit { // ZERO tolerance - all debt must be fully repaid // Since getTotalDebt rounds UP, this should never fail with proper repayment - assert( - false, - message: "Debt not fully repaid for \(balance.vaultType.identifier): \(balance.balance) remaining. Position cannot be closed with outstanding debt." - ) + panic("Debt not fully repaid for \(balance.vaultType.identifier): \(balance.balance) remaining. Position cannot be closed with outstanding debt.") } } - // Step 5: Withdraw all collateral + capped overpayment dust (deterministic order) + // Step 5: Withdraw all credit balances (collateral + any overpayment from sources) let positionView = self.buildPositionView(pid: pid) let collateralVaults: @[{FungibleToken.Vault}] <- [] - let withdrawalsByType: {Type: UFix64} = {} // Track all withdrawals (collateral + overpayment) + let withdrawalsByType: {Type: UFix64} = {} // Track withdrawals for event - // Build ORDERED, deduplicated withdrawal list: - // 1. Collateral types first (from position analysis) - // 2. Overpayment types second (if not already in collateral list) + // Build ordered list of token types to withdraw + // (collateral types identified in Step 2) let orderedWithdrawalTypes: [Type] = [] let seen: {Type: Bool} = {} @@ -3193,80 +3197,63 @@ access(all) contract FlowALPv0 { seen[collateralType] = true } } - for overpaymentType in overpaymentsByType.keys { - if seen[overpaymentType] == nil { - orderedWithdrawalTypes.append(overpaymentType) - seen[overpaymentType] = true - } - } - // Withdraw each type in deterministic order + // Withdraw all credit balances in deterministic order for withdrawalType in orderedWithdrawalTypes { let tokenBalance = positionView.trueBalance(ofToken: withdrawalType) let withdrawable = FlowALPMath.toUFix64RoundDown(tokenBalance) - // Determine withdrawal amount: - // - For overpayment types: withdraw UP TO overpayment amount (capped to actual balance) - // - For collateral types: withdraw full balance - var withdrawAmount: UFix64 = 0.0 - if overpaymentsByType.containsKey(withdrawalType) { - // Withdraw min(computed overpayment, actual withdrawable balance) - // Handles rounding differences between external balance view and internal scaled balances - let overpaymentAmount = overpaymentsByType[withdrawalType]! - withdrawAmount = overpaymentAmount < withdrawable ? overpaymentAmount : withdrawable - } else if withdrawable > 0.0 { - // Full collateral withdrawal - withdrawAmount = withdrawable - } + // Withdraw full balance (any overpayment naturally became credit in Step 3) + var withdrawAmount: UFix64 = withdrawable // Perform direct withdrawal while holding lock - if withdrawAmount > 0.0 { - let position = self._borrowPosition(pid: pid) - let tokenState = self._borrowUpdatedTokenState(type: withdrawalType) - let reserveVault = (&self.reserves[withdrawalType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - - // Record withdrawal in position balance - if position.balances[withdrawalType] == nil { - position.balances[withdrawalType] = InternalBalance( - direction: BalanceDirection.Credit, - scaledBalance: 0.0 - ) - } - position.balances[withdrawalType]!.recordWithdrawal( - amount: UFix128(withdrawAmount), - tokenState: tokenState + if withdrawAmount == 0.0 { + // Track zero withdrawal for this type + withdrawalsByType[withdrawalType] = 0.0 + collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(withdrawalType)) + continue + } + let position = self._borrowPosition(pid: pid) + let tokenState = self._borrowUpdatedTokenState(type: withdrawalType) + let reserveVault = (&self.reserves[withdrawalType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! + + // Record withdrawal in position balance + if position.balances[withdrawalType] == nil { + position.balances[withdrawalType] = InternalBalance( + direction: BalanceDirection.Credit, + scaledBalance: 0.0 ) + } + position.balances[withdrawalType]!.recordWithdrawal( + amount: UFix128(withdrawAmount), + tokenState: tokenState + ) - // Queue for update if necessary - self._queuePositionForUpdateIfNecessary(pid: pid) + // Queue for update if necessary + self._queuePositionForUpdateIfNecessary(pid: pid) - // Withdraw from reserves - let withdrawn <- reserveVault.withdraw(amount: withdrawAmount) + // Withdraw from reserves + let withdrawn <- reserveVault.withdraw(amount: withdrawAmount) - // Track withdrawal amount for this type - withdrawalsByType[withdrawalType] = withdrawAmount + // Track withdrawal amount for this type + withdrawalsByType[withdrawalType] = withdrawAmount - emit Withdrawn( - pid: pid, - poolUUID: self.uuid, - vaultType: withdrawalType, - amount: withdrawAmount, - withdrawnUUID: withdrawn.uuid - ) + emit Withdrawn( + pid: pid, + poolUUID: self.uuid, + vaultType: withdrawalType, + amount: withdrawAmount, + withdrawnUUID: withdrawn.uuid + ) - collateralVaults.append(<- withdrawn) - } else { - // Track zero withdrawal for this type - withdrawalsByType[withdrawalType] = 0.0 - collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(withdrawalType)) - } + collateralVaults.append(<- withdrawn) } - // Emit event for position closure with detailed breakdown - // Convert Type keys to String identifiers for event (dictionaries are deterministic) + // Emit event for position closure + // Note: repayments = debts owed (sources may have provided more, but that became credit) let repaymentsEvent: {String: UFix64} = {} - for repaymentType in repaymentsByType.keys { - repaymentsEvent[repaymentType.identifier] = repaymentsByType[repaymentType]! + for debtType in debtsByType.keys { + repaymentsEvent[debtType.identifier] = debtsByType[debtType]! } let withdrawalsEvent: {String: UFix64} = {} @@ -4105,42 +4092,32 @@ access(all) contract FlowALPv0 { return pool.getPositionDetails(pid: self.id).balances } - /// Returns the total debt information for this position, grouped by token type. + /// Returns the total debt for this position, grouped by token type. /// This is a convenience method for strategies to avoid recalculating debt from balances. /// - /// This method now supports multiple debt token types. It returns an array of DebtInfo, - /// one for each token type that has outstanding debt. + /// Supports multiple debt token types - returns a dictionary mapping each debt token type + /// to its outstanding amount. /// - /// Returns exact debt amounts - no buffer needed since measurement and repayment happen - /// in the same transaction (no interest accrual between reads). - /// - /// @return Array of DebtInfo structs, one per debt token type. Empty array if no debt. - access(all) fun getTotalDebt(): [DebtInfo] { + /// @return Dictionary mapping token Type to debt amount. Empty if no debt. + access(all) fun getTotalDebt(): {Type: UFix64} { let pool = self.pool.borrow()! let balances = pool.getPositionDetails(pid: self.id).balances let debtsByType: {Type: UFix64} = {} - // Group debts by token type + // Collect debts by token type for balance in balances { if balance.direction == BalanceDirection.Debit { let tokenType = balance.vaultType - let currentDebt = debtsByType[tokenType] ?? 0.0 - debtsByType[tokenType] = currentDebt + balance.balance + // Sanity check: should only be one balance entry per type + assert( + debtsByType[tokenType] == nil, + message: "Duplicate debt entry for \(tokenType.identifier)" + ) + debtsByType[tokenType] = balance.balance } } - // Convert to array of DebtInfo - let debts: [DebtInfo] = [] - for tokenType in debtsByType.keys { - let amount = debtsByType[tokenType]! - debts.append(DebtInfo(amount: amount, tokenType: tokenType)) - } - - // NOTE: Strategies using this must ensure their swap sources have sufficient - // liquidity. SwapSource.minimumAvailable() may return slightly less than - // actual debt due to source liquidity constraints or precision loss in - // swap calculations. Strategies should handle this appropriately. - return debts + return debtsByType } /// Returns the balance available for withdrawal of a given Vault type. If pullFromTopUpSource is true, the @@ -4270,12 +4247,12 @@ access(all) contract FlowALPv0 { /// @return Array of vaults containing all collateral + any overpayment dust /// access(FungibleToken.Withdraw) fun closePosition( - repaymentVaults: @[{FungibleToken.Vault}] + repaymentSources: [{DeFiActions.Source}] ): @[{FungibleToken.Vault}] { let pool = self.pool.borrow()! return <- pool.closePosition( pid: self.id, - repaymentVaults: <-repaymentVaults + repaymentSources: repaymentSources ) } @@ -4633,23 +4610,6 @@ access(all) contract FlowALPv0 { /// /// A structure returned externally to report a position's balance for a particular token. /// This structure is NOT used internally. - /// DebtInfo - /// - /// A structure returned by getTotalDebt() to report debt information for a specific token type. - /// getTotalDebt() returns an array of these, one per debt token type. - access(all) struct DebtInfo { - /// The total amount of debt for this token type - access(all) let amount: UFix64 - - /// The type of the debt token (nil if no debt) - access(all) let tokenType: Type? - - init(amount: UFix64, tokenType: Type?) { - self.amount = amount - self.tokenType = tokenType - } - } - access(all) struct PositionBalance { /// The token type for which the balance details relate to diff --git a/cadence/transactions/flow-alp/position/repay_and_close_position.cdc b/cadence/transactions/flow-alp/position/repay_and_close_position.cdc index 4404829e..152de97e 100644 --- a/cadence/transactions/flow-alp/position/repay_and_close_position.cdc +++ b/cadence/transactions/flow-alp/position/repay_and_close_position.cdc @@ -1,17 +1,20 @@ -// Repay MOET debt and close position, withdrawing all collateral +// Repay debt and close position using Sources (supports swapping, multi-vault, etc.) // -// This transaction uses the closePosition method to: -// 1. Repay all debt with provided MOET vault -// 2. Withdraw and return all collateral to the user +// This transaction uses the closePosition method with Source abstraction: +// 1. Creates a VaultSource from the user's MOET vault capability +// 2. closePosition pulls exactly what it needs from the source +// 3. Returns all collateral + any overpayment // -// After running this transaction: -// - MOET debt will be repaid (balance goes to 0) -// - All collateral will be returned to the user's vault -// - The position will be closed +// Benefits: +// - No debt precalculation needed in transaction +// - No buffer required +// - Supports swapping (can use SwapSource instead of VaultSource) +// - Contract handles all precision internally import "FungibleToken" import "FlowToken" import "DeFiActions" +import "FungibleTokenConnectors" import "FlowALPv0" import "MOET" @@ -20,9 +23,9 @@ transaction(positionId: UInt64) { let position: auth(FungibleToken.Withdraw) &FlowALPv0.Position let flowReceiverRef: &{FungibleToken.Receiver} let moetReceiverRef: &{FungibleToken.Receiver} - let moetWithdrawRef: auth(FungibleToken.Withdraw) &{FungibleToken.Vault} + let moetVaultCap: Capability - prepare(borrower: auth(BorrowValue) &Account) { + prepare(borrower: auth(BorrowValue, Capabilities) &Account) { // Borrow the PositionManager from constant storage path with both required entitlements let manager = borrower.storage.borrow( from: FlowALPv0.PositionStoragePath @@ -40,32 +43,25 @@ transaction(positionId: UInt64) { MOET.VaultPublicPath ) ?? panic("Could not borrow MOET receiver reference") - // Borrow withdraw reference to borrower's MOET vault to repay debt - self.moetWithdrawRef = borrower.storage.borrow(from: MOET.VaultStoragePath) - ?? panic("No MOET vault in storage") + // Get or create capability for MOET vault + self.moetVaultCap = borrower.capabilities.storage.issue( + MOET.VaultStoragePath + ) + assert(self.moetVaultCap.check(), message: "Invalid MOET vault capability") } execute { - // Calculate exact MOET debt from position - let debts = self.position.getTotalDebt() - var moetDebt: UFix64 = 0.0 - for debt in debts { - if debt.tokenType == Type<@MOET.Vault>() { - moetDebt = debt.amount - break - } - } - - // Withdraw exact MOET debt amount (rounded up by getTotalDebt) - // No buffer needed - contract now properly flips to credit when debt == 0 - let repaymentVaults: @[{FungibleToken.Vault}] <- [] - if moetDebt > 0.0 { - repaymentVaults.append(<- self.moetWithdrawRef.withdraw(amount: moetDebt)) - } + // Create a VaultSource from the MOET vault capability + // closePosition will pull exactly what it needs + let moetSource = FungibleTokenConnectors.VaultSource( + min: nil, // No minimum balance requirement + withdrawVault: self.moetVaultCap, + uniqueID: nil + ) - // Close position: repay debt and withdraw all collateral in one call - // Any overpayment will be returned along with collateral - let returnedVaults <- self.position.closePosition(repaymentVaults: <-repaymentVaults) + // Close position with sources + // Contract calculates debt internally and pulls exact amount needed + let returnedVaults <- self.position.closePosition(repaymentSources: [moetSource]) // Deposit all returned collateral and overpayment to appropriate vaults while returnedVaults.length > 0 { @@ -83,4 +79,4 @@ transaction(positionId: UInt64) { } destroy returnedVaults } -} +} From a11028e066813e0b6e53117f8e8e1745b603e09e Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:32:24 -0500 Subject: [PATCH 34/56] remove redundent seen check --- cadence/contracts/FlowALPv0.cdc | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 1a610c93..3448baa3 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3186,20 +3186,9 @@ access(all) contract FlowALPv0 { let collateralVaults: @[{FungibleToken.Vault}] <- [] let withdrawalsByType: {Type: UFix64} = {} // Track withdrawals for event - // Build ordered list of token types to withdraw - // (collateral types identified in Step 2) - let orderedWithdrawalTypes: [Type] = [] - let seen: {Type: Bool} = {} - - for collateralType in collateralTypes { - if seen[collateralType] == nil { - orderedWithdrawalTypes.append(collateralType) - seen[collateralType] = true - } - } - // Withdraw all credit balances in deterministic order - for withdrawalType in orderedWithdrawalTypes { + // (collateralTypes already deduplicated by Step 2 sanity check) + for withdrawalType in collateralTypes { let tokenBalance = positionView.trueBalance(ofToken: withdrawalType) let withdrawable = FlowALPMath.toUFix64RoundDown(tokenBalance) From c864297e693e97d4f4247d13cc9ffcc31b0cc52c Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:18:57 -0500 Subject: [PATCH 35/56] split into helper functions --- cadence/contracts/FlowALPv0.cdc | 185 ++++++++++++++++++++------------ 1 file changed, 117 insertions(+), 68 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 3448baa3..d25a43be 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1467,6 +1467,18 @@ access(all) contract FlowALPv0 { } } + /// Helper struct for position closure analysis results + /// Note: Type declarations must be public in Cadence, but this is only used internally + access(all) struct PositionClosureAnalysis { + access(all) let debtsByType: {Type: UFix64} + access(all) let collateralTypes: [Type] + + init(debtsByType: {Type: UFix64}, collateralTypes: [Type]) { + self.debtsByType = debtsByType + self.collateralTypes = collateralTypes + } + } + /// Pool /// /// A Pool is the primary logic for protocol operations. It contains the global state of all positions, @@ -3059,53 +3071,8 @@ access(all) contract FlowALPv0 { return <- withdrawn } - /// Closes a position using the position's configured topUpSource for debt repayment. - /// This is a convenience method that accesses the topUpSource directly. - /// Closes a position by repaying all debts with pre-prepared vaults and returning all collateral. - /// - /// This is the ONLY close method - users must prepare repayment funds externally. - /// This design eliminates circular dependencies and gives users full control over fund sourcing. - /// - /// Overpayment Handling (Strict): - /// - Overpayment becomes a credit balance via _depositEffectsOnly - /// - Close withdraws UP TO the computed overpayment amount (capped by actual withdrawable credit) - /// - Capping handles rounding differences between external balance view and internal scaled balances - /// - This prevents accidentally withdrawing unintended pre-existing credits in debt token types - /// - No epsilon tolerance: ALL debt must be exactly repaid (overpayment okay, underpayment fails) - /// - /// Steps: - /// 1. Locks the position - /// 2. Analyzes position to find all debt and collateral types - /// 3. Pulls from sources to repay debts (any overpayment becomes credit balance) - /// 4. Verifies NO debt remains (zero tolerance for unpaid debt) - /// 5. Automatically withdraws ALL collateral + any credit balances - /// 6. Returns array of vaults (collateral + any excess funds) - /// - /// @param pid: Position ID to close - /// @param repaymentSources: Array of Sources that can provide funds to repay debts - /// Sources are pulled from as needed (supports swapping, multi-vault, etc.) - /// @return Array of vaults containing all collateral + overpayment dust - /// - access(EPosition) fun closePosition( - pid: UInt64, - repaymentSources: [{DeFiActions.Source}] - ): @[{FungibleToken.Vault}] { - pre { - !self.isPausedOrWarmup(): "Operations are paused by governance" - self.positions[pid] != nil: "Invalid position ID" - } - post { - self.positionLock[pid] == nil: "Position is not unlocked" - } - - if self.debugLogging { - log(" [CONTRACT] closePosition(pid: \(pid), repaymentSources: \(repaymentSources.length))") - } - - // Step 1: Lock the position for all state modifications - self._lockPosition(pid) - - // Step 2: Analyze position to find all debt and collateral types + /// Analyzes a position to identify debts and collateral for closure + access(self) fun _analyzePositionForClosure(pid: UInt64): PositionClosureAnalysis { let positionDetails = self.getPositionDetails(pid: pid) let debtsByType: {Type: UFix64} = {} let collateralTypes: [Type] = [] @@ -3121,7 +3088,6 @@ access(all) contract FlowALPv0 { debtsByType[debtType] = balance.balance } else if balance.direction == BalanceDirection.Credit { // Track ALL collateral types present in position (including dust) - // Note: balance.balance may round to 0 but position might still have dust // Sanity check: each position should have at most one balance entry per token type assert( !collateralTypes.contains(balance.vaultType), @@ -3131,16 +3097,22 @@ access(all) contract FlowALPv0 { } } - // Step 3: Pull repayment from sources - // Note: Any overpayment naturally becomes a credit balance and is withdrawn in Step 5 + return PositionClosureAnalysis(debtsByType: debtsByType, collateralTypes: collateralTypes) + } + /// Repays all debts by pulling from sources + access(self) fun _repayDebtsFromSources( + pid: UInt64, + debtsByType: {Type: UFix64}, + sources: [{DeFiActions.Source}] + ) { // For each debt type, try to pull from sources for debtType in debtsByType.keys { let debtAmount = debtsByType[debtType]! var remainingDebt = debtAmount // Try each source until debt is fully paid - for source in repaymentSources { + for source in sources { if remainingDebt == 0.0 { break } @@ -3153,7 +3125,6 @@ access(all) contract FlowALPv0 { if pulledAmount > 0.0 { remainingDebt = remainingDebt - pulledAmount - // Deposit to position (any overpayment flips to credit) self._depositEffectsOnly(pid: pid, from: <-pulled) } else { @@ -3168,40 +3139,43 @@ access(all) contract FlowALPv0 { message: "Insufficient funds from sources for \(debtType.identifier) debt: needed \(debtAmount), got \(debtAmount - remainingDebt)" ) } + } - // Step 4: Verify ALL debt is EXACTLY repaid (no epsilon tolerance) + /// Verifies that no debt remains in the position + access(self) fun _verifyNoDebtRemains(pid: UInt64) { let updatedDetails = self.getPositionDetails(pid: pid) // CRITICAL: No debt tokens should remain in debit (zero tolerance) for balance in updatedDetails.balances { if balance.direction == BalanceDirection.Debit { - // ZERO tolerance - all debt must be fully repaid - // Since getTotalDebt rounds UP, this should never fail with proper repayment panic("Debt not fully repaid for \(balance.vaultType.identifier): \(balance.balance) remaining. Position cannot be closed with outstanding debt.") } } + } - // Step 5: Withdraw all credit balances (collateral + any overpayment from sources) + /// Withdraws all collateral from the position. + /// + /// Returns an array of vaults in the same order as the collateralTypes parameter. + /// This ordering guarantee allows the caller to pair vaults with their types. + access(self) fun _withdrawAllCollateral( + pid: UInt64, + collateralTypes: [Type] + ): @[{FungibleToken.Vault}] { let positionView = self.buildPositionView(pid: pid) let collateralVaults: @[{FungibleToken.Vault}] <- [] - let withdrawalsByType: {Type: UFix64} = {} // Track withdrawals for event // Withdraw all credit balances in deterministic order - // (collateralTypes already deduplicated by Step 2 sanity check) for withdrawalType in collateralTypes { let tokenBalance = positionView.trueBalance(ofToken: withdrawalType) let withdrawable = FlowALPMath.toUFix64RoundDown(tokenBalance) - - // Withdraw full balance (any overpayment naturally became credit in Step 3) - var withdrawAmount: UFix64 = withdrawable + let withdrawAmount = withdrawable // Perform direct withdrawal while holding lock if withdrawAmount == 0.0 { - // Track zero withdrawal for this type - withdrawalsByType[withdrawalType] = 0.0 collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(withdrawalType)) continue } + let position = self._borrowPosition(pid: pid) let tokenState = self._borrowUpdatedTokenState(type: withdrawalType) let reserveVault = (&self.reserves[withdrawalType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! @@ -3224,9 +3198,6 @@ access(all) contract FlowALPv0 { // Withdraw from reserves let withdrawn <- reserveVault.withdraw(amount: withdrawAmount) - // Track withdrawal amount for this type - withdrawalsByType[withdrawalType] = withdrawAmount - emit Withdrawn( pid: pid, poolUUID: self.uuid, @@ -3238,6 +3209,15 @@ access(all) contract FlowALPv0 { collateralVaults.append(<- withdrawn) } + return <- collateralVaults + } + + /// Emits the PositionClosed event + access(self) fun _emitPositionClosedEvent( + pid: UInt64, + debtsByType: {Type: UFix64}, + withdrawalsByType: {Type: UFix64} + ) { // Emit event for position closure // Note: repayments = debts owed (sources may have provided more, but that became credit) let repaymentsEvent: {String: UFix64} = {} @@ -3256,11 +3236,80 @@ access(all) contract FlowALPv0 { repaymentsByType: repaymentsEvent, withdrawalsByType: withdrawalsEvent ) + } + + /// Closes a position by repaying all debts from sources and returning all collateral. + /// + /// Users provide Source(s) that can supply funds to repay debts. The contract pulls exactly + /// what it needs to repay all debts. Sources support swapping, multi-vault, and other patterns + /// via the DeFiActions.Source abstraction. + /// + /// Overpayment Handling: + /// - Any overpayment automatically becomes a credit balance via _depositEffectsOnly + /// - All credit balances (collateral + any overpayment) are withdrawn and returned + /// - No epsilon tolerance: ALL debt must be exactly repaid (overpayment okay, underpayment fails) + /// + /// Steps: + /// 1. Locks the position + /// 2. Analyzes position to find all debt and collateral types + /// 3. Pulls from sources to repay debts (overpayment becomes credit balance) + /// 4. Verifies NO debt remains (zero tolerance for unpaid debt) + /// 5. Withdraws ALL collateral + any credit balances + /// 6. Builds withdrawals map for event emission + /// 7. Emits PositionClosed event + /// 8. Unlocks position + /// + /// @param pid: Position ID to close + /// @param repaymentSources: Array of Sources that can provide funds to repay debts + /// Sources are pulled from as needed (supports swapping, multi-vault, etc.) + /// @return Array of vaults containing all collateral + any overpayment + /// + access(EPosition) fun closePosition( + pid: UInt64, + repaymentSources: [{DeFiActions.Source}] + ): @[{FungibleToken.Vault}] { + pre { + !self.isPausedOrWarmup(): "Operations are paused by governance" + self.positions[pid] != nil: "Invalid position ID" + } + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } + + if self.debugLogging { + log(" [CONTRACT] closePosition(pid: \(pid), repaymentSources: \(repaymentSources.length))") + } + + // Step 1: Lock the position for all state modifications + self._lockPosition(pid) + + // Step 2: Analyze position to find all debt and collateral types + let analysis = self._analyzePositionForClosure(pid: pid) + + // Step 3: Repay all debts by pulling from sources + self._repayDebtsFromSources(pid: pid, debtsByType: analysis.debtsByType, sources: repaymentSources) + + // Step 4: Verify ALL debt is EXACTLY repaid (zero tolerance) + self._verifyNoDebtRemains(pid: pid) + + // Step 5: Withdraw all credit balances (collateral + any overpayment from sources) + let vaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: analysis.collateralTypes) + + // Step 6: Build withdrawals map for event (vaults are in same order as collateralTypes) + let withdrawalsByType: {Type: UFix64} = {} + var i = 0 + while i < analysis.collateralTypes.length { + withdrawalsByType[analysis.collateralTypes[i]] = vaults[i].balance + i = i + 1 + } + + // Step 7: Emit position closed event + self._emitPositionClosedEvent(pid: pid, debtsByType: analysis.debtsByType, withdrawalsByType: withdrawalsByType) - // Unlock position now that all operations are complete + // Step 8: Unlock position now that all operations are complete self._unlockPosition(pid) - return <-collateralVaults + return <- vaults } /////////////////////// From 0ed237b32bd1511c9808e0c2f720e4b515f147d5 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:27:56 -0500 Subject: [PATCH 36/56] remove unnecessary struct --- cadence/contracts/FlowALPv0.cdc | 55 +++++++++++++++++---------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index d25a43be..1ffe4272 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1467,18 +1467,6 @@ access(all) contract FlowALPv0 { } } - /// Helper struct for position closure analysis results - /// Note: Type declarations must be public in Cadence, but this is only used internally - access(all) struct PositionClosureAnalysis { - access(all) let debtsByType: {Type: UFix64} - access(all) let collateralTypes: [Type] - - init(debtsByType: {Type: UFix64}, collateralTypes: [Type]) { - self.debtsByType = debtsByType - self.collateralTypes = collateralTypes - } - } - /// Pool /// /// A Pool is the primary logic for protocol operations. It contains the global state of all positions, @@ -3071,11 +3059,11 @@ access(all) contract FlowALPv0 { return <- withdrawn } - /// Analyzes a position to identify debts and collateral for closure - access(self) fun _analyzePositionForClosure(pid: UInt64): PositionClosureAnalysis { + /// Gets all debts for a position + /// Returns a dictionary mapping token type to debt amount (rounded up to UFix64) + access(self) fun _getPositionDebts(pid: UInt64): {Type: UFix64} { let positionDetails = self.getPositionDetails(pid: pid) let debtsByType: {Type: UFix64} = {} - let collateralTypes: [Type] = [] for balance in positionDetails.balances { if balance.direction == BalanceDirection.Debit { @@ -3083,21 +3071,33 @@ access(all) contract FlowALPv0 { // Sanity check: each position should have at most one balance entry per token type assert( debtsByType[debtType] == nil, - message: "Sanity check failed: found multiple balances for \(debtType.identifier) while closing position" + message: "Sanity check failed: found multiple balances for \(debtType.identifier)" ) debtsByType[debtType] = balance.balance - } else if balance.direction == BalanceDirection.Credit { - // Track ALL collateral types present in position (including dust) + } + } + + return debtsByType + } + + /// Gets all collateral types for a position + /// Returns an array of token types that have credit balances (including dust amounts) + access(self) fun _getPositionCollateralTypes(pid: UInt64): [Type] { + let positionDetails = self.getPositionDetails(pid: pid) + let collateralTypes: [Type] = [] + + for balance in positionDetails.balances { + if balance.direction == BalanceDirection.Credit { // Sanity check: each position should have at most one balance entry per token type assert( !collateralTypes.contains(balance.vaultType), - message: "Sanity check failed: found multiple balances for \(balance.vaultType.identifier) while closing position" + message: "Sanity check failed: found multiple balances for \(balance.vaultType.identifier)" ) collateralTypes.append(balance.vaultType) } } - return PositionClosureAnalysis(debtsByType: debtsByType, collateralTypes: collateralTypes) + return collateralTypes } /// Repays all debts by pulling from sources @@ -3283,28 +3283,29 @@ access(all) contract FlowALPv0 { // Step 1: Lock the position for all state modifications self._lockPosition(pid) - // Step 2: Analyze position to find all debt and collateral types - let analysis = self._analyzePositionForClosure(pid: pid) + // Step 2: Get all debts and collateral types from position + let debtsByType = self._getPositionDebts(pid: pid) + let collateralTypes = self._getPositionCollateralTypes(pid: pid) // Step 3: Repay all debts by pulling from sources - self._repayDebtsFromSources(pid: pid, debtsByType: analysis.debtsByType, sources: repaymentSources) + self._repayDebtsFromSources(pid: pid, debtsByType: debtsByType, sources: repaymentSources) // Step 4: Verify ALL debt is EXACTLY repaid (zero tolerance) self._verifyNoDebtRemains(pid: pid) // Step 5: Withdraw all credit balances (collateral + any overpayment from sources) - let vaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: analysis.collateralTypes) + let vaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: collateralTypes) // Step 6: Build withdrawals map for event (vaults are in same order as collateralTypes) let withdrawalsByType: {Type: UFix64} = {} var i = 0 - while i < analysis.collateralTypes.length { - withdrawalsByType[analysis.collateralTypes[i]] = vaults[i].balance + while i < collateralTypes.length { + withdrawalsByType[collateralTypes[i]] = vaults[i].balance i = i + 1 } // Step 7: Emit position closed event - self._emitPositionClosedEvent(pid: pid, debtsByType: analysis.debtsByType, withdrawalsByType: withdrawalsByType) + self._emitPositionClosedEvent(pid: pid, debtsByType: debtsByType, withdrawalsByType: withdrawalsByType) // Step 8: Unlock position now that all operations are complete self._unlockPosition(pid) From 252e6588986c6d847981221d260502023a06d909 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:34:51 -0500 Subject: [PATCH 37/56] address comments --- cadence/contracts/FlowALPv0.cdc | 41 ++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 1ffe4272..1bfeb5d8 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3101,35 +3101,44 @@ access(all) contract FlowALPv0 { } /// Repays all debts by pulling from sources + /// Optimized to O(n+m) by grouping sources by type first access(self) fun _repayDebtsFromSources( pid: UInt64, debtsByType: {Type: UFix64}, sources: [{DeFiActions.Source}] ) { - // For each debt type, try to pull from sources + // Step 1: Group sources by type (O(m) where m = number of sources) + let sourcesByType: {Type: [{DeFiActions.Source}]} = {} + for source in sources { + let sourceType = source.getSourceType() + if sourcesByType[sourceType] == nil { + sourcesByType[sourceType] = [] + } + sourcesByType[sourceType]!.append(source) + } + + // Step 2: For each debt type, pull from matching sources (O(n) where n = number of debt types) for debtType in debtsByType.keys { let debtAmount = debtsByType[debtType]! var remainingDebt = debtAmount - // Try each source until debt is fully paid - for source in sources { + // Only iterate through sources that match this debt type + let matchingSources = sourcesByType[debtType] ?? [] + for source in matchingSources { if remainingDebt == 0.0 { break } - // Only pull from sources that provide the debt type we need - if source.getSourceType() == debtType { - // Pull up to remaining debt amount - let pulled <- source.withdrawAvailable(maxAmount: remainingDebt) - let pulledAmount = pulled.balance + // Pull up to remaining debt amount + let pulled <- source.withdrawAvailable(maxAmount: remainingDebt) + let pulledAmount = pulled.balance - if pulledAmount > 0.0 { - remainingDebt = remainingDebt - pulledAmount - // Deposit to position (any overpayment flips to credit) - self._depositEffectsOnly(pid: pid, from: <-pulled) - } else { - destroy pulled - } + if pulledAmount > 0.0 { + remainingDebt = remainingDebt - pulledAmount + // Deposit to position (any overpayment flips to credit) + self._depositEffectsOnly(pid: pid, from: <-pulled) + } else { + destroy pulled } } @@ -3285,7 +3294,6 @@ access(all) contract FlowALPv0 { // Step 2: Get all debts and collateral types from position let debtsByType = self._getPositionDebts(pid: pid) - let collateralTypes = self._getPositionCollateralTypes(pid: pid) // Step 3: Repay all debts by pulling from sources self._repayDebtsFromSources(pid: pid, debtsByType: debtsByType, sources: repaymentSources) @@ -3294,6 +3302,7 @@ access(all) contract FlowALPv0 { self._verifyNoDebtRemains(pid: pid) // Step 5: Withdraw all credit balances (collateral + any overpayment from sources) + let collateralTypes = self._getPositionCollateralTypes(pid: pid) let vaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: collateralTypes) // Step 6: Build withdrawals map for event (vaults are in same order as collateralTypes) From 7a769c8c38fbd246467e81a2d9a2d17cb3703a4f Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:09:17 -0500 Subject: [PATCH 38/56] handle queued deposits --- cadence/contracts/FlowALPv0.cdc | 64 +++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 1bfeb5d8..747ffb6b 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3059,6 +3059,22 @@ access(all) contract FlowALPv0 { return <- withdrawn } + /// Extracts all queued deposits from a position + /// Returns an array of vaults containing queued deposits that were never processed + access(self) fun _extractQueuedDeposits(pid: UInt64): @[{FungibleToken.Vault}] { + let position = self._borrowPosition(pid: pid) + let queuedVaults: @[{FungibleToken.Vault}] <- [] + + // Extract all queued deposits (funds that were deposited but not yet processed) + let queuedTypes = position.queuedDeposits.keys + for queuedType in queuedTypes { + let queuedVault <- position.queuedDeposits.remove(key: queuedType)! + queuedVaults.append(<- queuedVault) + } + + return <- queuedVaults + } + /// Gets all debts for a position /// Returns a dictionary mapping token type to debt amount (rounded up to UFix64) access(self) fun _getPositionDebts(pid: UInt64): {Type: UFix64} { @@ -3247,7 +3263,7 @@ access(all) contract FlowALPv0 { ) } - /// Closes a position by repaying all debts from sources and returning all collateral. + /// Closes a position by repaying all debts from sources and returning all funds. /// /// Users provide Source(s) that can supply funds to repay debts. The contract pulls exactly /// what it needs to repay all debts. Sources support swapping, multi-vault, and other patterns @@ -3258,20 +3274,27 @@ access(all) contract FlowALPv0 { /// - All credit balances (collateral + any overpayment) are withdrawn and returned /// - No epsilon tolerance: ALL debt must be exactly repaid (overpayment okay, underpayment fails) /// + /// Queued Deposits: + /// - Any deposits that were queued but not yet processed are extracted and returned + /// - These are funds that exceeded limits and were waiting for async processing + /// /// Steps: /// 1. Locks the position - /// 2. Analyzes position to find all debt and collateral types + /// 2. Gets all debts from position /// 3. Pulls from sources to repay debts (overpayment becomes credit balance) /// 4. Verifies NO debt remains (zero tolerance for unpaid debt) - /// 5. Withdraws ALL collateral + any credit balances - /// 6. Builds withdrawals map for event emission - /// 7. Emits PositionClosed event - /// 8. Unlocks position + /// 5. Gets collateral types (after repayment, to include any overpayment credits) + /// 6. Withdraws ALL collateral + any credit balances + /// 7. Builds withdrawals map for event emission + /// 8. Emits PositionClosed event + /// 9. Extracts any queued deposits (unprocessed funds to return) + /// 10. Unlocks position + /// 11. Combines queued deposits and collateral into return array /// /// @param pid: Position ID to close /// @param repaymentSources: Array of Sources that can provide funds to repay debts /// Sources are pulled from as needed (supports swapping, multi-vault, etc.) - /// @return Array of vaults containing all collateral + any overpayment + /// @return Array of vaults containing queued deposits + collateral + any overpayment /// access(EPosition) fun closePosition( pid: UInt64, @@ -3292,7 +3315,7 @@ access(all) contract FlowALPv0 { // Step 1: Lock the position for all state modifications self._lockPosition(pid) - // Step 2: Get all debts and collateral types from position + // Step 2: Get all debts from position let debtsByType = self._getPositionDebts(pid: pid) // Step 3: Repay all debts by pulling from sources @@ -3301,25 +3324,36 @@ access(all) contract FlowALPv0 { // Step 4: Verify ALL debt is EXACTLY repaid (zero tolerance) self._verifyNoDebtRemains(pid: pid) - // Step 5: Withdraw all credit balances (collateral + any overpayment from sources) + // Step 5: Get collateral types (AFTER repayment, in case overpayment created new credits) let collateralTypes = self._getPositionCollateralTypes(pid: pid) - let vaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: collateralTypes) - // Step 6: Build withdrawals map for event (vaults are in same order as collateralTypes) + // Step 6: Withdraw all credit balances (collateral + any overpayment from sources) + let collateralVaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: collateralTypes) + + // Step 7: Build withdrawals map for event (vaults are in same order as collateralTypes) let withdrawalsByType: {Type: UFix64} = {} var i = 0 while i < collateralTypes.length { - withdrawalsByType[collateralTypes[i]] = vaults[i].balance + withdrawalsByType[collateralTypes[i]] = collateralVaults[i].balance i = i + 1 } - // Step 7: Emit position closed event + // Step 8: Emit position closed event self._emitPositionClosedEvent(pid: pid, debtsByType: debtsByType, withdrawalsByType: withdrawalsByType) - // Step 8: Unlock position now that all operations are complete + // Step 9: Extract any queued deposits (unprocessed deposits to return) + let queuedVaults <- self._extractQueuedDeposits(pid: pid) + + // Step 10: Unlock position now that all operations are complete self._unlockPosition(pid) - return <- vaults + // Step 11: Combine queued deposits and collateral into single return array + while queuedVaults.length > 0 { + collateralVaults.append(<- queuedVaults.removeFirst()) + } + destroy queuedVaults + + return <- collateralVaults } /////////////////////// From 0117b9c68fad04a6672f979bc86908c388934c2a Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:12:50 -0500 Subject: [PATCH 39/56] Apply suggestions from code review Co-authored-by: Jordan Schalm --- cadence/contracts/FlowALPv0.cdc | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 747ffb6b..3b2984b6 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3170,7 +3170,9 @@ access(all) contract FlowALPv0 { access(self) fun _verifyNoDebtRemains(pid: UInt64) { let updatedDetails = self.getPositionDetails(pid: pid) - // CRITICAL: No debt tokens should remain in debit (zero tolerance) + // CRITICAL: No debt tokens should remain in debit. (zero tolerance) + // If a position has a zero balance in some token, that is represented as BalanceDirection.Credit, + // so we don't need to check balance amount here (any debit balance must be non-zero). for balance in updatedDetails.balances { if balance.direction == BalanceDirection.Debit { panic("Debt not fully repaid for \(balance.vaultType.identifier): \(balance.balance) remaining. Position cannot be closed with outstanding debt.") @@ -3192,8 +3194,7 @@ access(all) contract FlowALPv0 { // Withdraw all credit balances in deterministic order for withdrawalType in collateralTypes { let tokenBalance = positionView.trueBalance(ofToken: withdrawalType) - let withdrawable = FlowALPMath.toUFix64RoundDown(tokenBalance) - let withdrawAmount = withdrawable + let withdrawAmount = FlowALPMath.toUFix64RoundDown(tokenBalance) // Perform direct withdrawal while holding lock if withdrawAmount == 0.0 { @@ -3332,10 +3333,8 @@ access(all) contract FlowALPv0 { // Step 7: Build withdrawals map for event (vaults are in same order as collateralTypes) let withdrawalsByType: {Type: UFix64} = {} - var i = 0 - while i < collateralTypes.length { - withdrawalsByType[collateralTypes[i]] = collateralVaults[i].balance - i = i + 1 + for i in InclusiveRange(0, collateralTypes.length-1) { + withdrawalsByType[collateralTypes[i]] = vaults[i].balance } // Step 8: Emit position closed event @@ -4325,7 +4324,7 @@ access(all) contract FlowALPv0 { /// Automatically detects and withdraws all collateral types in the position. /// If repayment vaults contain overpayment, the excess is returned as dust. /// - /// @param repaymentVaults: Array of vaults containing funds to repay all debts (overpayment okay, underpayment fails) + /// @param repaymentSources: Array of sources (one per debt type) from which debt repayments can be withdrawn /// @return Array of vaults containing all collateral + any overpayment dust /// access(FungibleToken.Withdraw) fun closePosition( From 5e19226d96cde1d70e1364c4f65100871b9dc0ec Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:26:22 -0500 Subject: [PATCH 40/56] address PR comments, add queued deposits test --- cadence/contracts/FlowALPv0.cdc | 47 ++- .../tests/close_position_dust_return_test.cdc | 331 ++++++++++++++++ ...close_position_queued_overpayment_test.cdc | 373 ++++++++++++++++++ ...ose_position_rounding_overpayment_test.cdc | 221 +++++++++++ .../position/deposit_to_position_by_id.cdc | 32 ++ 5 files changed, 992 insertions(+), 12 deletions(-) create mode 100644 cadence/tests/close_position_dust_return_test.cdc create mode 100644 cadence/tests/close_position_queued_overpayment_test.cdc create mode 100644 cadence/tests/close_position_rounding_overpayment_test.cdc create mode 100644 cadence/tests/transactions/position/deposit_to_position_by_id.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 3b2984b6..8f2df464 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -317,13 +317,15 @@ access(all) contract FlowALPv0 { interestIndex: tokenState.debitInterestIndex ) - // Harmonize comparison with withdrawal: treat an exact match as "does not flip to credit" + // Use >= comparison to match withdrawal pattern (both use >= for consistency). + // When deposit exactly equals debt, we enter this branch and check if balance reaches zero. if trueBalance >= amount { // The deposit isn't big enough to clear the debt, // so we just decrement the debt. let updatedBalance = trueBalance - amount - // If debt is fully repaid (updatedBalance == 0), flip to credit + // Special case: If debt is fully repaid (exact match), flip to Credit with zero balance. + // This ensures a position with zero debt is always represented as Credit, not Debit. if updatedBalance == 0.0 { self.direction = BalanceDirection.Credit self.scaledBalance = 0.0 @@ -2753,6 +2755,10 @@ access(all) contract FlowALPv0 { ) { pre { !self.isPaused(): "Withdrawal, deposits, and liquidations are paused by governance" + self.positionLock[pid] == true: "Position is not locked" + } + post { + self.positionLock[pid] == true: "Position is not locked" } // NOTE: caller must have already validated pid + token support let amount = from.balance @@ -3062,6 +3068,12 @@ access(all) contract FlowALPv0 { /// Extracts all queued deposits from a position /// Returns an array of vaults containing queued deposits that were never processed access(self) fun _extractQueuedDeposits(pid: UInt64): @[{FungibleToken.Vault}] { + pre { + self.positionLock[pid] == true: "Position is not locked" + } + post { + self.positionLock[pid] == true: "Position is not locked" + } let position = self._borrowPosition(pid: pid) let queuedVaults: @[{FungibleToken.Vault}] <- [] @@ -3123,6 +3135,12 @@ access(all) contract FlowALPv0 { debtsByType: {Type: UFix64}, sources: [{DeFiActions.Source}] ) { + pre { + self.positionLock[pid] == true: "Position is not locked" + } + post { + self.positionLock[pid] == true: "Position is not locked" + } // Step 1: Group sources by type (O(m) where m = number of sources) let sourcesByType: {Type: [{DeFiActions.Source}]} = {} for source in sources { @@ -3147,6 +3165,8 @@ access(all) contract FlowALPv0 { // Pull up to remaining debt amount let pulled <- source.withdrawAvailable(maxAmount: remainingDebt) + assert(pulled.getType() == debtType, message: "repayment type doesn't match debt type") + let pulledAmount = pulled.balance if pulledAmount > 0.0 { @@ -3188,6 +3208,12 @@ access(all) contract FlowALPv0 { pid: UInt64, collateralTypes: [Type] ): @[{FungibleToken.Vault}] { + pre { + self.positionLock[pid] == true: "Position is not locked" + } + post { + self.positionLock[pid] == true: "Position is not locked" + } let positionView = self.buildPositionView(pid: pid) let collateralVaults: @[{FungibleToken.Vault}] <- [] @@ -3198,7 +3224,6 @@ access(all) contract FlowALPv0 { // Perform direct withdrawal while holding lock if withdrawAmount == 0.0 { - collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(withdrawalType)) continue } @@ -3270,11 +3295,6 @@ access(all) contract FlowALPv0 { /// what it needs to repay all debts. Sources support swapping, multi-vault, and other patterns /// via the DeFiActions.Source abstraction. /// - /// Overpayment Handling: - /// - Any overpayment automatically becomes a credit balance via _depositEffectsOnly - /// - All credit balances (collateral + any overpayment) are withdrawn and returned - /// - No epsilon tolerance: ALL debt must be exactly repaid (overpayment okay, underpayment fails) - /// /// Queued Deposits: /// - Any deposits that were queued but not yet processed are extracted and returned /// - These are funds that exceeded limits and were waiting for async processing @@ -3329,7 +3349,7 @@ access(all) contract FlowALPv0 { let collateralTypes = self._getPositionCollateralTypes(pid: pid) // Step 6: Withdraw all credit balances (collateral + any overpayment from sources) - let collateralVaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: collateralTypes) + let vaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: collateralTypes) // Step 7: Build withdrawals map for event (vaults are in same order as collateralTypes) let withdrawalsByType: {Type: UFix64} = {} @@ -3348,11 +3368,11 @@ access(all) contract FlowALPv0 { // Step 11: Combine queued deposits and collateral into single return array while queuedVaults.length > 0 { - collateralVaults.append(<- queuedVaults.removeFirst()) + vaults.append(<- queuedVaults.removeFirst()) } destroy queuedVaults - return <- collateralVaults + return <- vaults } /////////////////////// @@ -3708,6 +3728,10 @@ access(all) contract FlowALPv0 { access(self) fun _rebalancePositionNoLock(pid: UInt64, force: Bool) { pre { !self.isPaused(): "Withdrawal, deposits, and liquidations are paused by governance" + self.positionLock[pid] == true: "Position is not locked" + } + post { + self.positionLock[pid] == true: "Position is not locked" } if self.debugLogging { log(" [CONTRACT] rebalancePosition(pid: \(pid), force: \(force))") @@ -4322,7 +4346,6 @@ access(all) contract FlowALPv0 { /// See Pool.closePosition() for detailed implementation documentation. /// /// Automatically detects and withdraws all collateral types in the position. - /// If repayment vaults contain overpayment, the excess is returned as dust. /// /// @param repaymentSources: Array of sources (one per debt type) from which debt repayments can be withdrawn /// @return Array of vaults containing all collateral + any overpayment dust diff --git a/cadence/tests/close_position_dust_return_test.cdc b/cadence/tests/close_position_dust_return_test.cdc new file mode 100644 index 00000000..0bc2c60d --- /dev/null +++ b/cadence/tests/close_position_dust_return_test.cdc @@ -0,0 +1,331 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowToken" +import "FlowALPv0" +import "FlowALPMath" +import "test_helpers.cdc" + +// ----------------------------------------------------------------------------- +// Close Position: Dust Return from Rounding Error Test +// +// This test demonstrates that when the protocol withdraws more from a source +// than the actual internal debt (due to conservative rounding UP), the excess +// "dust" is correctly returned to the user as collateral. +// +// Strategy: +// 1. Create position with debt +// 2. Use oracle price changes to create complex internal debt values +// 3. The debt has high precision at UFix128 level (many decimal places) +// 4. When converted to UFix64 and rounded UP, there's a measurable difference +// 5. The excess withdrawn from source becomes credit and is returned +// ----------------------------------------------------------------------------- + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +access(all) +fun setup() { + deployContracts() + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + snapshot = getCurrentBlockHeight() +} + +// ============================================================================= +// Test: Dust return via oracle price manipulation +// ============================================================================= +access(all) +fun test_closePosition_dustReturnFromRounding() { + safeReset() + log("\n=== Test: Dust Return from Rounding Error (via Price Changes) ===") + + // Start with price = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Configure token with high limits + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 10_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Step 1: Open position with 1000 FLOW and borrow MOET + log("\n📍 Step 1: Open position with 1000 FLOW") + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [1000.0, FLOW_VAULT_STORAGE_PATH, true], // pushToDrawDownSink = true to borrow + user + ) + Test.expect(openRes, Test.beSucceeded()) + + let positionDetails1 = getPositionDetails(pid: UInt64(0), beFailed: false) + var initialDebt: UFix64 = 0.0 + for balance in positionDetails1.balances { + if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { + initialDebt = balance.balance + } + } + log("Initial MOET debt: ".concat(initialDebt.toString())) + + // Step 2: Change price to create complex internal state + // Price changes cause health calculations and potential rebalancing + log("\n📍 Step 2: Change Flow price to 1.12345678") + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.12345678) + + // Force rebalance to apply price change effects (must be signed by pool owner) + let rebalance1 = _executeTransaction( + "../transactions/flow-alp/pool-management/rebalance_position.cdc", + [UInt64(0), true], + PROTOCOL_ACCOUNT + ) + Test.expect(rebalance1, Test.beSucceeded()) + + // Step 3: Change price again to accumulate more precision + log("\n📍 Step 3: Change Flow price to 0.98765432") + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.98765432) + + let rebalance2 = _executeTransaction( + "../transactions/flow-alp/pool-management/rebalance_position.cdc", + [UInt64(0), true], + PROTOCOL_ACCOUNT + ) + Test.expect(rebalance2, Test.beSucceeded()) + + // Step 4: Change price to a value with many decimal places + log("\n📍 Step 4: Change Flow price to 1.11111111 (many decimals)") + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.11111111) + + let rebalance3 = _executeTransaction( + "../transactions/flow-alp/pool-management/rebalance_position.cdc", + [UInt64(0), true], + PROTOCOL_ACCOUNT + ) + Test.expect(rebalance3, Test.beSucceeded()) + + // Step 5: Deposit a fractional amount to create more precision + log("\n📍 Step 5: Deposit fractional Flow to create precision") + let depositRes = _executeTransaction( + "./transactions/position/deposit_to_position_by_id.cdc", + [UInt64(0), 123.45678901, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(depositRes, Test.beSucceeded()) + + // Step 6: Get debt details BEFORE closing + log("\n📍 Step 6: Check debt before closure") + let positionDetailsBefore = getPositionDetails(pid: UInt64(0), beFailed: false) + + var moetDebtUFix64: UFix64 = 0.0 + log("Position balances:") + for balance in positionDetailsBefore.balances { + log(" - ".concat(balance.vaultType.identifier) + .concat(": ") + .concat(balance.balance.toString()) + .concat(" (") + .concat(balance.direction == FlowALPv0.BalanceDirection.Credit ? "Credit" : "Debit") + .concat(")")) + + if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { + moetDebtUFix64 = balance.balance + } + } + + log("\n🔍 MOET debt (rounded UP to UFix64): ".concat(moetDebtUFix64.toString())) + Test.assert(moetDebtUFix64 > 0.0, message: "Position should have MOET debt") + + // Step 7: Get balances before close + let moetBalanceBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + log("\n💰 Balances before closure:") + log(" User MOET balance: ".concat(moetBalanceBefore.toString())) + log(" User Flow balance: ".concat(flowBalanceBefore.toString())) + + // Step 8: Close position + // The protocol will: + // 1. Get debt as UFix64 (rounded UP from internal UFix128) + // 2. Withdraw that amount from VaultSource (exact amount) + // 3. Deposit to position - if rounded debt > actual debt, excess becomes credit + // 4. Return all credits including the dust overpayment + log("\n📍 Step 8: Close position") + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + // Step 9: Check final balances + let moetBalanceAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + let flowBalanceAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + log("\n💰 Balances after closure:") + log(" User MOET balance: ".concat(moetBalanceAfter.toString())) + log(" User Flow balance: ".concat(flowBalanceAfter.toString())) + + let flowChange = flowBalanceAfter - flowBalanceBefore + + log("\n📊 Changes:") + if moetBalanceAfter >= moetBalanceBefore { + let moetGain = moetBalanceAfter - moetBalanceBefore + log(" MOET change: +".concat(moetGain.toString()).concat(" (DUST RETURNED!)")) + } else { + let moetUsed = moetBalanceBefore - moetBalanceAfter + log(" MOET change: -".concat(moetUsed.toString()).concat(" (used for debt repayment)")) + } + log(" Flow change: +".concat(flowChange.toString()).concat(" (collateral returned)")) + + // Assertions + Test.assert(flowChange > 1000.0, message: "Should receive back collateral (1000+ Flow)") + + // Key assertion: Check if there's measurable MOET dust returned + // Due to conservative rounding UP of debt, there may be a tiny overpayment + // that gets returned as MOET collateral + if moetBalanceAfter > 0.0 { + log("\n✨ DUST DETECTED! ✨") + log("🔬 MOET dust returned: ".concat(moetBalanceAfter.toString())) + log("📝 This is the overpayment from conservative rounding (UFix128 → UFix64)") + log("💡 The protocol withdrew more than the actual internal debt") + log(" and correctly returned the excess as collateral!") + + // The dust should be very small (< 0.01 MOET) + Test.assert(moetBalanceAfter < 0.01, message: "Dust should be very small") + } else { + log("\n📝 No measurable MOET dust at UFix64 precision") + log(" (Overpayment may exist at UFix128 level but rounds to zero at UFix64)") + log(" Try with more extreme price changes or fractional operations") + } + + log("\n✅ Position closed successfully") + log("✅ Debt was repaid with conservative rounding UP") + log("✅ Any overpayment dust was correctly returned as collateral") +} + +// ============================================================================= +// Test 2: Extreme price volatility to maximize rounding error +// ============================================================================= +access(all) +fun test_closePosition_extremePriceVolatility() { + safeReset() + log("\n=== Test: Extreme Price Volatility for Maximum Rounding Error ===") + + // Start with a non-round price + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.33333333) + + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.75, // 0.75 creates more complex calculations + borrowFactor: 0.95, // Non-1.0 borrow factor adds complexity + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 10_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with a fractional amount + log("\n📍 Open position with 777.77777701 FLOW (fractional)") + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [777.77777701, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Extreme price swings with fractional values + let prices = [1.98765432, 0.54321098, 2.11111111, 0.77777777, 1.45678901] + var priceIndex = 0 + + while priceIndex < prices.length { + let price = prices[priceIndex] + log("\n🔄 Price change #".concat(priceIndex.toString()).concat(": ").concat(price.toString())) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: price) + + let rebalanceRes = _executeTransaction( + "../transactions/flow-alp/pool-management/rebalance_position.cdc", + [UInt64(0), true], + PROTOCOL_ACCOUNT + ) + Test.expect(rebalanceRes, Test.beSucceeded()) + + priceIndex = priceIndex + 1 + } + + // Multiple fractional deposits to accumulate precision + log("\n📍 Multiple fractional deposits") + let depositAmounts = [11.11111101, 22.22222202, 33.33333303] + var depositIndex = 0 + + while depositIndex < depositAmounts.length { + let amount = depositAmounts[depositIndex] + let depositRes = _executeTransaction( + "./transactions/position/deposit_to_position_by_id.cdc", + [UInt64(0), amount, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(depositRes, Test.beSucceeded()) + depositIndex = depositIndex + 1 + } + + // Check debt before closure + let positionDetails = getPositionDetails(pid: UInt64(0), beFailed: false) + var moetDebt: UFix64 = 0.0 + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { + moetDebt = balance.balance + log("\n💵 MOET debt (UFix64): ".concat(moetDebt.toString())) + } + } + + let moetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + let flowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + // Close position + log("\n📍 Closing position...") + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + let moetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + let flowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + log("\n📊 Final Results:") + log(" MOET before: ".concat(moetBefore.toString()).concat(" → after: ").concat(moetAfter.toString())) + log(" Flow before: ".concat(flowBefore.toString()).concat(" → after: ").concat(flowAfter.toString())) + + if moetAfter > 0.0 { + log("\n✨✨✨ SUCCESS! DUST RETURNED! ✨✨✨") + log("🎯 MOET dust: ".concat(moetAfter.toString())) + log("🔬 This proves the protocol correctly returns overpayment dust") + log("📐 Rounding UFix128 debt UP to UFix64 created measurable excess") + log("✅ The excess was deposited, flipped to credit, and returned!") + } else { + log("\n📝 Even with extreme volatility, dust is below UFix64 precision") + log(" The mechanism is still working at UFix128 level internally") + } + + Test.assert(flowAfter > flowBefore, message: "Should receive Flow collateral back") + log("\n✅ Test completed successfully") +} diff --git a/cadence/tests/close_position_queued_overpayment_test.cdc b/cadence/tests/close_position_queued_overpayment_test.cdc new file mode 100644 index 00000000..0ae20179 --- /dev/null +++ b/cadence/tests/close_position_queued_overpayment_test.cdc @@ -0,0 +1,373 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowToken" +import "FlowALPv0" +import "FlowALPMath" +import "test_helpers.cdc" + +// ----------------------------------------------------------------------------- +// Close Position: Queued Deposits & Overpayment Test Suite +// +// Tests that position closure correctly handles: +// 1. Queued deposits that were not yet processed +// 2. Overpayment during debt repayment that becomes collateral +// ----------------------------------------------------------------------------- + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +access(all) +fun setup() { + deployContracts() + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + snapshot = getCurrentBlockHeight() +} + +// ============================================================================= +// Test 1: Close position with queued deposits +// ============================================================================= +access(all) +fun test_closePosition_withQueuedDeposits() { + safeReset() + log("\n=== Test: Close Position with Queued Deposits ===") + + // Setup: price = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Configure token with low deposit limit to force queuing + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 100.0, // Low limit to force queuing + depositCapacityCap: 100.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 10_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with 50 FLOW (within limit) + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [50.0, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Get initial Flow balance + let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("Flow balance after first deposit: ".concat(flowBalanceBefore.toString())) + + // Try to deposit another 150 FLOW - this should exceed the limit (50 + 150 > 100) + // and cause some amount (100 FLOW) to be queued + let depositRes = _executeTransaction( + "./transactions/position/deposit_to_position_by_id.cdc", + [UInt64(0), 150.0, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(depositRes, Test.beSucceeded()) + + // Get Flow balance after deposit + let flowBalanceAfterDeposit = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("Flow balance after second deposit: ".concat(flowBalanceAfterDeposit.toString())) + + // The position can only hold 100 FLOW max, so ~100 FLOW should be queued + // User should have ~9800 FLOW (10000 - 50 - 150) + let expectedAfterDeposit = 10_000.0 - 50.0 - 150.0 + Test.assert(flowBalanceAfterDeposit >= expectedAfterDeposit - 1.0, message: "Should have withdrawn full deposit amount") + Test.assert(flowBalanceAfterDeposit <= expectedAfterDeposit + 1.0, message: "Should have withdrawn full deposit amount") + + // Mint MOET for closing (tiny buffer for any precision) + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) + + // Close position - should return both processed collateral (50) AND queued deposits (~100) + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + // Get final Flow balance + let flowBalanceAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("Flow balance after close: ".concat(flowBalanceAfter.toString())) + + // User deposited 50 + 150 = 200 FLOW total + // With limit of 100, the breakdown is: + // - 50 FLOW processed (first deposit) + // - 50 FLOW processed (from second deposit, to reach 100 limit) + // - 100 FLOW queued (remainder from second deposit) + // + // On close, should get back: + // - 100 FLOW processed collateral + // - 100 FLOW queued deposits + // Total: 200 FLOW back + // + // Started: 10000, Withdrew: 200, Should get back: 200 + // Final: 10000 + let expectedFinal = 10_000.0 // All deposits returned + Test.assert(flowBalanceAfter >= expectedFinal - 10.0, message: "Should return all deposits (processed + queued)") + Test.assert(flowBalanceAfter <= expectedFinal + 10.0, message: "Should return all deposits (processed + queued)") + + log("✅ Successfully closed position with queued deposits returned") +} + +// ============================================================================= +// Test 2: Close position with overpayment +// ============================================================================= +access(all) +fun test_closePosition_withOverpayment() { + safeReset() + log("\n=== Test: Close Position with Overpayment ===") + + // Setup: price = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Configure token with high limits + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with 100 FLOW and borrow MOET + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], // pushToDrawDownSink = true to borrow + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Check MOET debt + let positionDetailsBefore = getPositionDetails(pid: UInt64(0), beFailed: false) + let debtBefore = positionDetailsBefore.balances[0].balance + log("Initial MOET debt: ".concat(debtBefore.toString())) + + // Verify there's debt + Test.assert(debtBefore > 0.0, message: "Position should have debt") + + // Get initial MOET balance + let moetBalanceBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + log("MOET balance before close: ".concat(moetBalanceBefore.toString())) + + // Mint extra MOET (overpayment) + let overpaymentAmount = 10.0 + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: overpaymentAmount, beFailed: false) + + let moetBalanceWithExtra = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + log("MOET balance with overpayment: ".concat(moetBalanceWithExtra.toString())) + + // Close position with overpayment + // The closePosition should: + // 1. Pull exact debt amount from MOET vault + // 2. Any extra pulled becomes credit balance + // 3. Return all credits (Flow collateral + MOET overpayment) + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + // Get final balances + let flowBalanceAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + let moetBalanceAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + + log("Flow balance after close: ".concat(flowBalanceAfter.toString())) + log("MOET balance after close: ".concat(moetBalanceAfter.toString())) + + // User started with 1000 FLOW, deposited 100, should get back ~100 + // Final balance should be close to 1000 FLOW + Test.assert(flowBalanceAfter >= 990.0, message: "Should have at least 990 FLOW total") + Test.assert(flowBalanceAfter <= 1010.0, message: "Should have at most 1010 FLOW total") + + // MOET balance should be approximately: (initial + overpayment - debt) + // Since overpayment > needed, some MOET should remain + // The contract pulls exactly what's needed, so any overpayment in the vault stays there + // But if overpayment was deposited and became credit, it should be returned + log("MOET returned/remaining: ".concat(moetBalanceAfter.toString())) + + log("✅ Successfully closed position with overpayment handled correctly") +} + +// ============================================================================= +// Test 3: Close position with both queued deposits and overpayment +// ============================================================================= +access(all) +fun test_closePosition_withQueuedAndOverpayment() { + safeReset() + log("\n=== Test: Close Position with Queued Deposits AND Overpayment ===") + + // Setup: price = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Configure token with moderate deposit limit + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 150.0, // Moderate limit + depositCapacityCap: 150.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 10_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with 100 FLOW and borrow + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], // Borrow MOET + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Get debt amount + let positionDetails1 = getPositionDetails(pid: UInt64(0), beFailed: false) + let debt = positionDetails1.balances[0].balance + log("MOET debt: ".concat(debt.toString())) + + // Try to deposit more Flow (should partially queue since limit is 150) + let depositRes = _executeTransaction( + "./transactions/position/deposit_to_position_by_id.cdc", + [UInt64(0), 100.0, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(depositRes, Test.beSucceeded()) + + // Get balances before close + let flowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + let moetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + + log("Flow before close: ".concat(flowBefore.toString())) + log("MOET before close: ".concat(moetBefore.toString())) + + // Mint extra MOET for overpayment + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 5.0, beFailed: false) + + // Close position - should return: + // 1. Processed Flow collateral + // 2. Queued Flow deposits (if any) + // 3. Any MOET overpayment (if it becomes credit) + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + // Get final balances + let flowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + let moetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + + log("Flow after close: ".concat(flowAfter.toString())) + log("MOET after close: ".concat(moetAfter.toString())) + + // User deposited 100 + 100 = 200 FLOW, with limit 150, so ~50 queued + // Should get back processed collateral + queued + // Final flow should be close to starting (minus any processed that stayed) + let flowReturned = flowAfter - flowBefore + log("Flow returned: ".concat(flowReturned.toString())) + + // Should return collateral + queued deposits + Test.assert(flowReturned >= 140.0, message: "Should return collateral + queued deposits") + Test.assert(flowReturned <= 210.0, message: "Should return collateral + queued deposits") + + log("✅ Successfully closed position with both queued deposits and overpayment") +} + +// ============================================================================= +// Test 4: Verify queued deposits are tracked and returned correctly +// ============================================================================= +access(all) +fun test_queuedDeposits_tracking() { + safeReset() + log("\n=== Test: Queued Deposits Tracking ===") + + // Setup with very low deposit limit + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 50.0, // Very low limit + depositCapacityCap: 50.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 10_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with small amount (within limit) + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [30.0, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + log("Initial deposit completed") + + // Deposit amount that exceeds limit (30 already in, limit is 50, so deposit 100) + // Should result in: 20 more processed (to hit 50 limit), 80 queued + let depositRes = _executeTransaction( + "./transactions/position/deposit_to_position_by_id.cdc", + [UInt64(0), 100.0, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(depositRes, Test.beSucceeded()) + + log("Large deposit completed - queuing should have occurred") + + // Close and verify queued deposits are returned + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) + + let flowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("Flow before close: ".concat(flowBefore.toString())) + + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + let flowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + let returned = flowAfter - flowBefore + + log("Flow after close: ".concat(flowAfter.toString())) + log("Total Flow returned: ".concat(returned.toString())) + + // Should return: + // - 50 FLOW processed collateral (30 initial + 20 from second deposit) + // - 80 FLOW queued deposits + // Total: ~130 FLOW + Test.assert(returned >= 125.0, message: "Should return at least 125 FLOW (collateral + queued)") + Test.assert(returned <= 135.0, message: "Should return at most 135 FLOW (collateral + queued)") + + log("✅ Queued deposits tracked and returned correctly") +} diff --git a/cadence/tests/close_position_rounding_overpayment_test.cdc b/cadence/tests/close_position_rounding_overpayment_test.cdc new file mode 100644 index 00000000..454dcb13 --- /dev/null +++ b/cadence/tests/close_position_rounding_overpayment_test.cdc @@ -0,0 +1,221 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowToken" +import "FlowALPv0" +import "FlowALPMath" +import "test_helpers.cdc" + +// ----------------------------------------------------------------------------- +// Close Position: Rounding-Induced Overpayment Test Suite +// +// Tests that position closure correctly handles overpayment that occurs due to +// conservative rounding when converting UFix128 debt to UFix64 for repayment. +// +// Key insight: +// - Internal debt is UFix128 (e.g., 100.00000000123456789) +// - getPositionDetails() rounds UP to UFix64 (e.g., 100.00000001) +// - Repayment of 100.00000001 (UFix64) becomes 100.00000001000000000 (UFix128) +// - Overpayment of ~0.00000000876543211 is created +// - This overpayment should flip to credit and be returned to the user +// ----------------------------------------------------------------------------- + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +access(all) +fun setup() { + deployContracts() + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + snapshot = getCurrentBlockHeight() +} + +// ============================================================================= +// Test: Rounding-induced overpayment during debt repayment +// ============================================================================= +access(all) +fun test_closePosition_roundingOverpayment() { + safeReset() + log("\n=== Test: Close Position with Rounding-Induced Overpayment ===") + + // Setup: price = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Configure token with high limits and interest rates to create non-round debt values + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // Set a small interest rate on MOET to create precise debt values + // Note: Even with zero rate curve, internal calculations may create precision + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with 100 FLOW and borrow MOET + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], // pushToDrawDownSink = true to borrow + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Get the debt details BEFORE closing + let positionDetailsBefore = getPositionDetails(pid: UInt64(0), beFailed: false) + + // Find the MOET debt balance (should be in Debit direction) + var moetDebt: UFix64 = 0.0 + for balance in positionDetailsBefore.balances { + if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { + moetDebt = balance.balance + log("MOET debt (rounded UP to UFix64): ".concat(moetDebt.toString())) + } + } + + // Verify there's debt + Test.assert(moetDebt > 0.0, message: "Position should have MOET debt") + + // Get user's MOET balance before close + let moetBalanceBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + log("User MOET balance before close: ".concat(moetBalanceBefore.toString())) + + // Get user's Flow balance before close + let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("User Flow balance before close: ".concat(flowBalanceBefore.toString())) + + // Close position + // The close operation will: + // 1. Get debt amount (UFix64, rounded UP from internal UFix128) + // 2. Withdraw exactly that amount from VaultSource + // 3. Deposit to position - if rounded debt > actual debt, overpayment flips to credit + // 4. Withdraw all credits (including the overpayment) + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + // Get final balances + let flowBalanceAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + let moetBalanceAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + + log("User Flow balance after close: ".concat(flowBalanceAfter.toString())) + log("User MOET balance after close: ".concat(moetBalanceAfter.toString())) + + // Calculate what was returned + let flowReturned = flowBalanceAfter - flowBalanceBefore + + log("Flow returned: ".concat(flowReturned.toString())) + log("MOET balance change: from ".concat(moetBalanceBefore.toString()).concat(" to ").concat(moetBalanceAfter.toString())) + + // Assertions: + // 1. Should get back ~100 FLOW (collateral) + Test.assert(flowReturned >= 99.0, message: "Should return at least 99 FLOW collateral") + Test.assert(flowReturned <= 101.0, message: "Should return at most 101 FLOW collateral") + + // 2. MOET was used to repay the debt (borrowed amount was consumed) + // The user borrowed moetBalanceBefore, and it was used for repayment + // After closure, MOET balance should be approximately 0 (or contain overpayment dust) + + // 3. Check if there was any MOET overpayment returned + // Due to rounding UP (UFix128 → UFix64), there may be a tiny overpayment + // that flips to credit and gets returned + if moetBalanceAfter > 0.0 { + log("🔍 Detected MOET overpayment returned: ".concat(moetBalanceAfter.toString())) + log(" This is the rounding-induced overpayment from debt repayment!") + } else { + log("📝 No measurable MOET overpayment at UFix64 precision") + log(" (Overpayment may exist at UFix128 precision but rounds to zero)") + } + + log("✅ Successfully closed position with rounding-based debt repayment") + log("Note: Overpayment from rounding UP debt (UFix128→UFix64) should flip to credit") + log(" and be returned. At UFix64 precision, this may appear as dust or zero.") +} + +// ============================================================================= +// Test: Multiple rebalances create precision-sensitive debt +// ============================================================================= +access(all) +fun test_closePosition_precisionDebtFromRebalances() { + safeReset() + log("\n=== Test: Close Position with Precision Debt from Multiple Rebalances ===") + + // Setup: price = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Configure token + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with 100 FLOW and borrow + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Note: Multiple rebalances could create complex UFix128 precision scenarios + // but for simplicity, we test with a single position state + + // Get debt after rebalances + let positionDetails = getPositionDetails(pid: UInt64(0), beFailed: false) + var moetDebt: UFix64 = 0.0 + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { + moetDebt = balance.balance + log("MOET debt after rebalances (rounded UP): ".concat(moetDebt.toString())) + } + } + + // Get balances before close + let moetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + let flowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + // Close position + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + // Get balances after close + let moetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + let flowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + log("MOET before: ".concat(moetBefore.toString()).concat(", after: ").concat(moetAfter.toString())) + log("Flow before: ".concat(flowBefore.toString()).concat(", after: ").concat(flowAfter.toString())) + + // Should get back Flow collateral + Test.assert(flowAfter > flowBefore, message: "Should receive Flow collateral back") + + log("✅ Position closed successfully after multiple rebalances") +} diff --git a/cadence/tests/transactions/position/deposit_to_position_by_id.cdc b/cadence/tests/transactions/position/deposit_to_position_by_id.cdc new file mode 100644 index 00000000..f2641af5 --- /dev/null +++ b/cadence/tests/transactions/position/deposit_to_position_by_id.cdc @@ -0,0 +1,32 @@ +import "FungibleToken" +import "FlowALPv0" + +/// Deposits funds to a position by position ID (using PositionManager) +/// +transaction(positionId: UInt64, amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: Bool) { + + let collateral: @{FungibleToken.Vault} + let position: auth(FungibleToken.Withdraw) &FlowALPv0.Position + let pushToDrawDownSink: Bool + + prepare(signer: auth(BorrowValue) &Account) { + // Withdraw the collateral from the signer's stored Vault + let collateralSource = signer.storage.borrow(from: vaultStoragePath) + ?? panic("Could not borrow reference to Vault from \(vaultStoragePath)") + self.collateral <- collateralSource.withdraw(amount: amount) + + // Borrow the PositionManager from storage + let manager = signer.storage.borrow( + from: FlowALPv0.PositionStoragePath + ) ?? panic("Could not find PositionManager in storage") + + // Borrow the position with withdraw entitlement + self.position = manager.borrowAuthorizedPosition(pid: positionId) as! auth(FungibleToken.Withdraw) &FlowALPv0.Position + self.pushToDrawDownSink = pushToDrawDownSink + } + + execute { + // Deposit to the position + self.position.depositAndPush(from: <-self.collateral, pushToDrawDownSink: self.pushToDrawDownSink) + } +} From cb98c6b494c69b5a6e96ee88386c6afebfda161a Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:20:56 -0500 Subject: [PATCH 41/56] fix looping --- cadence/contracts/FlowALPv0.cdc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 8f2df464..108b77b5 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3353,8 +3353,8 @@ access(all) contract FlowALPv0 { // Step 7: Build withdrawals map for event (vaults are in same order as collateralTypes) let withdrawalsByType: {Type: UFix64} = {} - for i in InclusiveRange(0, collateralTypes.length-1) { - withdrawalsByType[collateralTypes[i]] = vaults[i].balance + for i in InclusiveRange(0, vaults.length-1) { + withdrawalsByType[vaults[i].getType()] = vaults[i].balance } // Step 8: Emit position closed event From ae8e3b97707a937ed640bcc02587899d0d5eeb96 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:35:30 -0500 Subject: [PATCH 42/56] one source per debt --- cadence/contracts/FlowALPv0.cdc | 49 ++++++++++----------------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 108b77b5..142108b5 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3128,8 +3128,8 @@ access(all) contract FlowALPv0 { return collateralTypes } - /// Repays all debts by pulling from sources - /// Optimized to O(n+m) by grouping sources by type first + /// Validates that sources can cover all debt types before attempting repayment + /// Repays all debts by pulling from sources (exactly one source per debt type) access(self) fun _repayDebtsFromSources( pid: UInt64, debtsByType: {Type: UFix64}, @@ -3141,48 +3141,27 @@ access(all) contract FlowALPv0 { post { self.positionLock[pid] == true: "Position is not locked" } - // Step 1: Group sources by type (O(m) where m = number of sources) - let sourcesByType: {Type: [{DeFiActions.Source}]} = {} + + // Build source map and validate no duplicates + let sourcesByType: {Type: {DeFiActions.Source}} = {} for source in sources { let sourceType = source.getSourceType() - if sourcesByType[sourceType] == nil { - sourcesByType[sourceType] = [] + if sourcesByType[sourceType] != nil { + panic("Multiple sources provided for debt type: \(sourceType.identifier)") } - sourcesByType[sourceType]!.append(source) + sourcesByType[sourceType] = source } - // Step 2: For each debt type, pull from matching sources (O(n) where n = number of debt types) + // Repay each debt: find source, validate, and pay for debtType in debtsByType.keys { let debtAmount = debtsByType[debtType]! - var remainingDebt = debtAmount - - // Only iterate through sources that match this debt type - let matchingSources = sourcesByType[debtType] ?? [] - for source in matchingSources { - if remainingDebt == 0.0 { - break - } - - // Pull up to remaining debt amount - let pulled <- source.withdrawAvailable(maxAmount: remainingDebt) - assert(pulled.getType() == debtType, message: "repayment type doesn't match debt type") - - let pulledAmount = pulled.balance + let source = sourcesByType[debtType] ?? panic("No repayment source provided for debt type: \(debtType.identifier)") - if pulledAmount > 0.0 { - remainingDebt = remainingDebt - pulledAmount - // Deposit to position (any overpayment flips to credit) - self._depositEffectsOnly(pid: pid, from: <-pulled) - } else { - destroy pulled - } - } + let pulled <- source.withdrawAvailable(maxAmount: debtAmount) + assert(pulled.getType() == debtType, message: "Source returned wrong type: expected \(debtType.identifier), got \(pulled.getType().identifier)") + assert(pulled.balance >= debtAmount, message: "Insufficient funds from source for \(debtType.identifier) debt: needed \(debtAmount.toString()), got \(pulled.balance.toString())") - // Verify we got enough for this debt type - assert( - remainingDebt == 0.0, - message: "Insufficient funds from sources for \(debtType.identifier) debt: needed \(debtAmount), got \(debtAmount - remainingDebt)" - ) + self._depositEffectsOnly(pid: pid, from: <-pulled) } } From 530f3421864350ba6de01590578724fcb8875215 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:19:23 -0500 Subject: [PATCH 43/56] address comments, remove unnecessary tests --- cadence/contracts/FlowALPv0.cdc | 86 +++-- .../tests/close_position_dust_return_test.cdc | 331 ------------------ ...ose_position_rounding_overpayment_test.cdc | 221 ------------ 3 files changed, 41 insertions(+), 597 deletions(-) delete mode 100644 cadence/tests/close_position_dust_return_test.cdc delete mode 100644 cadence/tests/close_position_rounding_overpayment_test.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 142108b5..2723cdae 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3066,8 +3066,8 @@ access(all) contract FlowALPv0 { } /// Extracts all queued deposits from a position - /// Returns an array of vaults containing queued deposits that were never processed - access(self) fun _extractQueuedDeposits(pid: UInt64): @[{FungibleToken.Vault}] { + /// Returns a map of vault type to vault, guaranteeing no duplicate types. + access(self) fun _extractQueuedDeposits(pid: UInt64): @{Type: {FungibleToken.Vault}} { pre { self.positionLock[pid] == true: "Position is not locked" } @@ -3075,13 +3075,12 @@ access(all) contract FlowALPv0 { self.positionLock[pid] == true: "Position is not locked" } let position = self._borrowPosition(pid: pid) - let queuedVaults: @[{FungibleToken.Vault}] <- [] + let queuedVaults: @{Type: {FungibleToken.Vault}} <- {} - // Extract all queued deposits (funds that were deposited but not yet processed) let queuedTypes = position.queuedDeposits.keys for queuedType in queuedTypes { let queuedVault <- position.queuedDeposits.remove(key: queuedType)! - queuedVaults.append(<- queuedVault) + queuedVaults[queuedType] <-! queuedVault } return <- queuedVaults @@ -3181,12 +3180,11 @@ access(all) contract FlowALPv0 { /// Withdraws all collateral from the position. /// - /// Returns an array of vaults in the same order as the collateralTypes parameter. - /// This ordering guarantee allows the caller to pair vaults with their types. + /// Returns a map of vault type to vault, guaranteeing no duplicate types. access(self) fun _withdrawAllCollateral( pid: UInt64, collateralTypes: [Type] - ): @[{FungibleToken.Vault}] { + ): @{Type: {FungibleToken.Vault}} { pre { self.positionLock[pid] == true: "Position is not locked" } @@ -3194,14 +3192,13 @@ access(all) contract FlowALPv0 { self.positionLock[pid] == true: "Position is not locked" } let positionView = self.buildPositionView(pid: pid) - let collateralVaults: @[{FungibleToken.Vault}] <- [] + let collateralVaults: @{Type: {FungibleToken.Vault}} <- {} - // Withdraw all credit balances in deterministic order + // Withdraw all credit balances for withdrawalType in collateralTypes { let tokenBalance = positionView.trueBalance(ofToken: withdrawalType) let withdrawAmount = FlowALPMath.toUFix64RoundDown(tokenBalance) - // Perform direct withdrawal while holding lock if withdrawAmount == 0.0 { continue } @@ -3210,22 +3207,11 @@ access(all) contract FlowALPv0 { let tokenState = self._borrowUpdatedTokenState(type: withdrawalType) let reserveVault = (&self.reserves[withdrawalType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - // Record withdrawal in position balance - if position.balances[withdrawalType] == nil { - position.balances[withdrawalType] = InternalBalance( - direction: BalanceDirection.Credit, - scaledBalance: 0.0 - ) - } position.balances[withdrawalType]!.recordWithdrawal( amount: UFix128(withdrawAmount), tokenState: tokenState ) - // Queue for update if necessary - self._queuePositionForUpdateIfNecessary(pid: pid) - - // Withdraw from reserves let withdrawn <- reserveVault.withdraw(amount: withdrawAmount) emit Withdrawn( @@ -3236,7 +3222,7 @@ access(all) contract FlowALPv0 { withdrawnUUID: withdrawn.uuid ) - collateralVaults.append(<- withdrawn) + collateralVaults[withdrawalType] <-! withdrawn } return <- collateralVaults @@ -3284,17 +3270,16 @@ access(all) contract FlowALPv0 { /// 3. Pulls from sources to repay debts (overpayment becomes credit balance) /// 4. Verifies NO debt remains (zero tolerance for unpaid debt) /// 5. Gets collateral types (after repayment, to include any overpayment credits) - /// 6. Withdraws ALL collateral + any credit balances - /// 7. Builds withdrawals map for event emission - /// 8. Emits PositionClosed event - /// 9. Extracts any queued deposits (unprocessed funds to return) + /// 6. Withdraws ALL collateral + /// 7. Extracts queued deposits and merges into collateral map (dedup by type) + /// 8. Builds withdrawals map for event emission + /// 9. Emits PositionClosed event /// 10. Unlocks position - /// 11. Combines queued deposits and collateral into return array /// /// @param pid: Position ID to close /// @param repaymentSources: Array of Sources that can provide funds to repay debts /// Sources are pulled from as needed (supports swapping, multi-vault, etc.) - /// @return Array of vaults containing queued deposits + collateral + any overpayment + /// @return Array of vaults containing collateral + queued deposits + any overpayment, one per token type /// access(EPosition) fun closePosition( pid: UInt64, @@ -3327,31 +3312,42 @@ access(all) contract FlowALPv0 { // Step 5: Get collateral types (AFTER repayment, in case overpayment created new credits) let collateralTypes = self._getPositionCollateralTypes(pid: pid) - // Step 6: Withdraw all credit balances (collateral + any overpayment from sources) + // Step 6: Withdraw all credit balances let vaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: collateralTypes) - // Step 7: Build withdrawals map for event (vaults are in same order as collateralTypes) + // Step 7: Extract queued deposits and merge into collateral map (dedup by type) + let queuedVaults <- self._extractQueuedDeposits(pid: pid) + for queuedType in queuedVaults.keys { + let queuedVault <- queuedVaults.remove(key: queuedType)! + if vaults[queuedType] != nil { + let ref = (&vaults[queuedType] as &{FungibleToken.Vault}?)! + ref.deposit(from: <-queuedVault) + } else { + vaults[queuedType] <-! queuedVault + } + } + destroy queuedVaults + + // Step 8: Build withdrawals map for event (includes collateral + queued deposits) let withdrawalsByType: {Type: UFix64} = {} - for i in InclusiveRange(0, vaults.length-1) { - withdrawalsByType[vaults[i].getType()] = vaults[i].balance + for vaultType in vaults.keys { + let ref = (&vaults[vaultType] as &{FungibleToken.Vault}?)! + withdrawalsByType[vaultType] = ref.balance } - // Step 8: Emit position closed event + // Step 9: Emit position closed event self._emitPositionClosedEvent(pid: pid, debtsByType: debtsByType, withdrawalsByType: withdrawalsByType) - // Step 9: Extract any queued deposits (unprocessed deposits to return) - let queuedVaults <- self._extractQueuedDeposits(pid: pid) + // Step 10: Drain map into array and unlock + let returnVaults: @[{FungibleToken.Vault}] <- [] + for vaultType in vaults.keys { + returnVaults.append(<- vaults.remove(key: vaultType)!) + } + destroy vaults - // Step 10: Unlock position now that all operations are complete self._unlockPosition(pid) - // Step 11: Combine queued deposits and collateral into single return array - while queuedVaults.length > 0 { - vaults.append(<- queuedVaults.removeFirst()) - } - destroy queuedVaults - - return <- vaults + return <- returnVaults } /////////////////////// @@ -4327,7 +4323,7 @@ access(all) contract FlowALPv0 { /// Automatically detects and withdraws all collateral types in the position. /// /// @param repaymentSources: Array of sources (one per debt type) from which debt repayments can be withdrawn - /// @return Array of vaults containing all collateral + any overpayment dust + /// @return Array of vaults containing all collateral + queued deposits + any overpayment dust, one per token type /// access(FungibleToken.Withdraw) fun closePosition( repaymentSources: [{DeFiActions.Source}] diff --git a/cadence/tests/close_position_dust_return_test.cdc b/cadence/tests/close_position_dust_return_test.cdc deleted file mode 100644 index 0bc2c60d..00000000 --- a/cadence/tests/close_position_dust_return_test.cdc +++ /dev/null @@ -1,331 +0,0 @@ -import Test -import BlockchainHelpers - -import "MOET" -import "FlowToken" -import "FlowALPv0" -import "FlowALPMath" -import "test_helpers.cdc" - -// ----------------------------------------------------------------------------- -// Close Position: Dust Return from Rounding Error Test -// -// This test demonstrates that when the protocol withdraws more from a source -// than the actual internal debt (due to conservative rounding UP), the excess -// "dust" is correctly returned to the user as collateral. -// -// Strategy: -// 1. Create position with debt -// 2. Use oracle price changes to create complex internal debt values -// 3. The debt has high precision at UFix128 level (many decimal places) -// 4. When converted to UFix64 and rounded UP, there's a measurable difference -// 5. The excess withdrawn from source becomes credit and is returned -// ----------------------------------------------------------------------------- - -access(all) var snapshot: UInt64 = 0 - -access(all) -fun safeReset() { - let cur = getCurrentBlockHeight() - if cur > snapshot { - Test.reset(to: snapshot) - } -} - -access(all) -fun setup() { - deployContracts() - createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) - snapshot = getCurrentBlockHeight() -} - -// ============================================================================= -// Test: Dust return via oracle price manipulation -// ============================================================================= -access(all) -fun test_closePosition_dustReturnFromRounding() { - safeReset() - log("\n=== Test: Dust Return from Rounding Error (via Price Changes) ===") - - // Start with price = 1.0 - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - - // Configure token with high limits - addSupportedTokenZeroRateCurve( - signer: PROTOCOL_ACCOUNT, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - collateralFactor: 0.8, - borrowFactor: 1.0, - depositRate: 1_000_000.0, - depositCapacityCap: 1_000_000.0 - ) - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 10_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Step 1: Open position with 1000 FLOW and borrow MOET - log("\n📍 Step 1: Open position with 1000 FLOW") - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [1000.0, FLOW_VAULT_STORAGE_PATH, true], // pushToDrawDownSink = true to borrow - user - ) - Test.expect(openRes, Test.beSucceeded()) - - let positionDetails1 = getPositionDetails(pid: UInt64(0), beFailed: false) - var initialDebt: UFix64 = 0.0 - for balance in positionDetails1.balances { - if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { - initialDebt = balance.balance - } - } - log("Initial MOET debt: ".concat(initialDebt.toString())) - - // Step 2: Change price to create complex internal state - // Price changes cause health calculations and potential rebalancing - log("\n📍 Step 2: Change Flow price to 1.12345678") - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.12345678) - - // Force rebalance to apply price change effects (must be signed by pool owner) - let rebalance1 = _executeTransaction( - "../transactions/flow-alp/pool-management/rebalance_position.cdc", - [UInt64(0), true], - PROTOCOL_ACCOUNT - ) - Test.expect(rebalance1, Test.beSucceeded()) - - // Step 3: Change price again to accumulate more precision - log("\n📍 Step 3: Change Flow price to 0.98765432") - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.98765432) - - let rebalance2 = _executeTransaction( - "../transactions/flow-alp/pool-management/rebalance_position.cdc", - [UInt64(0), true], - PROTOCOL_ACCOUNT - ) - Test.expect(rebalance2, Test.beSucceeded()) - - // Step 4: Change price to a value with many decimal places - log("\n📍 Step 4: Change Flow price to 1.11111111 (many decimals)") - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.11111111) - - let rebalance3 = _executeTransaction( - "../transactions/flow-alp/pool-management/rebalance_position.cdc", - [UInt64(0), true], - PROTOCOL_ACCOUNT - ) - Test.expect(rebalance3, Test.beSucceeded()) - - // Step 5: Deposit a fractional amount to create more precision - log("\n📍 Step 5: Deposit fractional Flow to create precision") - let depositRes = _executeTransaction( - "./transactions/position/deposit_to_position_by_id.cdc", - [UInt64(0), 123.45678901, FLOW_VAULT_STORAGE_PATH, false], - user - ) - Test.expect(depositRes, Test.beSucceeded()) - - // Step 6: Get debt details BEFORE closing - log("\n📍 Step 6: Check debt before closure") - let positionDetailsBefore = getPositionDetails(pid: UInt64(0), beFailed: false) - - var moetDebtUFix64: UFix64 = 0.0 - log("Position balances:") - for balance in positionDetailsBefore.balances { - log(" - ".concat(balance.vaultType.identifier) - .concat(": ") - .concat(balance.balance.toString()) - .concat(" (") - .concat(balance.direction == FlowALPv0.BalanceDirection.Credit ? "Credit" : "Debit") - .concat(")")) - - if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { - moetDebtUFix64 = balance.balance - } - } - - log("\n🔍 MOET debt (rounded UP to UFix64): ".concat(moetDebtUFix64.toString())) - Test.assert(moetDebtUFix64 > 0.0, message: "Position should have MOET debt") - - // Step 7: Get balances before close - let moetBalanceBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - - log("\n💰 Balances before closure:") - log(" User MOET balance: ".concat(moetBalanceBefore.toString())) - log(" User Flow balance: ".concat(flowBalanceBefore.toString())) - - // Step 8: Close position - // The protocol will: - // 1. Get debt as UFix64 (rounded UP from internal UFix128) - // 2. Withdraw that amount from VaultSource (exact amount) - // 3. Deposit to position - if rounded debt > actual debt, excess becomes credit - // 4. Return all credits including the dust overpayment - log("\n📍 Step 8: Close position") - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(0)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - // Step 9: Check final balances - let moetBalanceAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - let flowBalanceAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - - log("\n💰 Balances after closure:") - log(" User MOET balance: ".concat(moetBalanceAfter.toString())) - log(" User Flow balance: ".concat(flowBalanceAfter.toString())) - - let flowChange = flowBalanceAfter - flowBalanceBefore - - log("\n📊 Changes:") - if moetBalanceAfter >= moetBalanceBefore { - let moetGain = moetBalanceAfter - moetBalanceBefore - log(" MOET change: +".concat(moetGain.toString()).concat(" (DUST RETURNED!)")) - } else { - let moetUsed = moetBalanceBefore - moetBalanceAfter - log(" MOET change: -".concat(moetUsed.toString()).concat(" (used for debt repayment)")) - } - log(" Flow change: +".concat(flowChange.toString()).concat(" (collateral returned)")) - - // Assertions - Test.assert(flowChange > 1000.0, message: "Should receive back collateral (1000+ Flow)") - - // Key assertion: Check if there's measurable MOET dust returned - // Due to conservative rounding UP of debt, there may be a tiny overpayment - // that gets returned as MOET collateral - if moetBalanceAfter > 0.0 { - log("\n✨ DUST DETECTED! ✨") - log("🔬 MOET dust returned: ".concat(moetBalanceAfter.toString())) - log("📝 This is the overpayment from conservative rounding (UFix128 → UFix64)") - log("💡 The protocol withdrew more than the actual internal debt") - log(" and correctly returned the excess as collateral!") - - // The dust should be very small (< 0.01 MOET) - Test.assert(moetBalanceAfter < 0.01, message: "Dust should be very small") - } else { - log("\n📝 No measurable MOET dust at UFix64 precision") - log(" (Overpayment may exist at UFix128 level but rounds to zero at UFix64)") - log(" Try with more extreme price changes or fractional operations") - } - - log("\n✅ Position closed successfully") - log("✅ Debt was repaid with conservative rounding UP") - log("✅ Any overpayment dust was correctly returned as collateral") -} - -// ============================================================================= -// Test 2: Extreme price volatility to maximize rounding error -// ============================================================================= -access(all) -fun test_closePosition_extremePriceVolatility() { - safeReset() - log("\n=== Test: Extreme Price Volatility for Maximum Rounding Error ===") - - // Start with a non-round price - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.33333333) - - addSupportedTokenZeroRateCurve( - signer: PROTOCOL_ACCOUNT, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - collateralFactor: 0.75, // 0.75 creates more complex calculations - borrowFactor: 0.95, // Non-1.0 borrow factor adds complexity - depositRate: 1_000_000.0, - depositCapacityCap: 1_000_000.0 - ) - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 10_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position with a fractional amount - log("\n📍 Open position with 777.77777701 FLOW (fractional)") - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [777.77777701, FLOW_VAULT_STORAGE_PATH, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - // Extreme price swings with fractional values - let prices = [1.98765432, 0.54321098, 2.11111111, 0.77777777, 1.45678901] - var priceIndex = 0 - - while priceIndex < prices.length { - let price = prices[priceIndex] - log("\n🔄 Price change #".concat(priceIndex.toString()).concat(": ").concat(price.toString())) - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: price) - - let rebalanceRes = _executeTransaction( - "../transactions/flow-alp/pool-management/rebalance_position.cdc", - [UInt64(0), true], - PROTOCOL_ACCOUNT - ) - Test.expect(rebalanceRes, Test.beSucceeded()) - - priceIndex = priceIndex + 1 - } - - // Multiple fractional deposits to accumulate precision - log("\n📍 Multiple fractional deposits") - let depositAmounts = [11.11111101, 22.22222202, 33.33333303] - var depositIndex = 0 - - while depositIndex < depositAmounts.length { - let amount = depositAmounts[depositIndex] - let depositRes = _executeTransaction( - "./transactions/position/deposit_to_position_by_id.cdc", - [UInt64(0), amount, FLOW_VAULT_STORAGE_PATH, false], - user - ) - Test.expect(depositRes, Test.beSucceeded()) - depositIndex = depositIndex + 1 - } - - // Check debt before closure - let positionDetails = getPositionDetails(pid: UInt64(0), beFailed: false) - var moetDebt: UFix64 = 0.0 - for balance in positionDetails.balances { - if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { - moetDebt = balance.balance - log("\n💵 MOET debt (UFix64): ".concat(moetDebt.toString())) - } - } - - let moetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - let flowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - - // Close position - log("\n📍 Closing position...") - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(0)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - let moetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - let flowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - - log("\n📊 Final Results:") - log(" MOET before: ".concat(moetBefore.toString()).concat(" → after: ").concat(moetAfter.toString())) - log(" Flow before: ".concat(flowBefore.toString()).concat(" → after: ").concat(flowAfter.toString())) - - if moetAfter > 0.0 { - log("\n✨✨✨ SUCCESS! DUST RETURNED! ✨✨✨") - log("🎯 MOET dust: ".concat(moetAfter.toString())) - log("🔬 This proves the protocol correctly returns overpayment dust") - log("📐 Rounding UFix128 debt UP to UFix64 created measurable excess") - log("✅ The excess was deposited, flipped to credit, and returned!") - } else { - log("\n📝 Even with extreme volatility, dust is below UFix64 precision") - log(" The mechanism is still working at UFix128 level internally") - } - - Test.assert(flowAfter > flowBefore, message: "Should receive Flow collateral back") - log("\n✅ Test completed successfully") -} diff --git a/cadence/tests/close_position_rounding_overpayment_test.cdc b/cadence/tests/close_position_rounding_overpayment_test.cdc deleted file mode 100644 index 454dcb13..00000000 --- a/cadence/tests/close_position_rounding_overpayment_test.cdc +++ /dev/null @@ -1,221 +0,0 @@ -import Test -import BlockchainHelpers - -import "MOET" -import "FlowToken" -import "FlowALPv0" -import "FlowALPMath" -import "test_helpers.cdc" - -// ----------------------------------------------------------------------------- -// Close Position: Rounding-Induced Overpayment Test Suite -// -// Tests that position closure correctly handles overpayment that occurs due to -// conservative rounding when converting UFix128 debt to UFix64 for repayment. -// -// Key insight: -// - Internal debt is UFix128 (e.g., 100.00000000123456789) -// - getPositionDetails() rounds UP to UFix64 (e.g., 100.00000001) -// - Repayment of 100.00000001 (UFix64) becomes 100.00000001000000000 (UFix128) -// - Overpayment of ~0.00000000876543211 is created -// - This overpayment should flip to credit and be returned to the user -// ----------------------------------------------------------------------------- - -access(all) var snapshot: UInt64 = 0 - -access(all) -fun safeReset() { - let cur = getCurrentBlockHeight() - if cur > snapshot { - Test.reset(to: snapshot) - } -} - -access(all) -fun setup() { - deployContracts() - createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) - snapshot = getCurrentBlockHeight() -} - -// ============================================================================= -// Test: Rounding-induced overpayment during debt repayment -// ============================================================================= -access(all) -fun test_closePosition_roundingOverpayment() { - safeReset() - log("\n=== Test: Close Position with Rounding-Induced Overpayment ===") - - // Setup: price = 1.0 - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - - // Configure token with high limits and interest rates to create non-round debt values - addSupportedTokenZeroRateCurve( - signer: PROTOCOL_ACCOUNT, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - collateralFactor: 0.8, - borrowFactor: 1.0, - depositRate: 1_000_000.0, - depositCapacityCap: 1_000_000.0 - ) - - // Set a small interest rate on MOET to create precise debt values - // Note: Even with zero rate curve, internal calculations may create precision - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 1_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position with 100 FLOW and borrow MOET - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [100.0, FLOW_VAULT_STORAGE_PATH, true], // pushToDrawDownSink = true to borrow - user - ) - Test.expect(openRes, Test.beSucceeded()) - - // Get the debt details BEFORE closing - let positionDetailsBefore = getPositionDetails(pid: UInt64(0), beFailed: false) - - // Find the MOET debt balance (should be in Debit direction) - var moetDebt: UFix64 = 0.0 - for balance in positionDetailsBefore.balances { - if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { - moetDebt = balance.balance - log("MOET debt (rounded UP to UFix64): ".concat(moetDebt.toString())) - } - } - - // Verify there's debt - Test.assert(moetDebt > 0.0, message: "Position should have MOET debt") - - // Get user's MOET balance before close - let moetBalanceBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - log("User MOET balance before close: ".concat(moetBalanceBefore.toString())) - - // Get user's Flow balance before close - let flowBalanceBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - log("User Flow balance before close: ".concat(flowBalanceBefore.toString())) - - // Close position - // The close operation will: - // 1. Get debt amount (UFix64, rounded UP from internal UFix128) - // 2. Withdraw exactly that amount from VaultSource - // 3. Deposit to position - if rounded debt > actual debt, overpayment flips to credit - // 4. Withdraw all credits (including the overpayment) - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(0)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - // Get final balances - let flowBalanceAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - let moetBalanceAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - - log("User Flow balance after close: ".concat(flowBalanceAfter.toString())) - log("User MOET balance after close: ".concat(moetBalanceAfter.toString())) - - // Calculate what was returned - let flowReturned = flowBalanceAfter - flowBalanceBefore - - log("Flow returned: ".concat(flowReturned.toString())) - log("MOET balance change: from ".concat(moetBalanceBefore.toString()).concat(" to ").concat(moetBalanceAfter.toString())) - - // Assertions: - // 1. Should get back ~100 FLOW (collateral) - Test.assert(flowReturned >= 99.0, message: "Should return at least 99 FLOW collateral") - Test.assert(flowReturned <= 101.0, message: "Should return at most 101 FLOW collateral") - - // 2. MOET was used to repay the debt (borrowed amount was consumed) - // The user borrowed moetBalanceBefore, and it was used for repayment - // After closure, MOET balance should be approximately 0 (or contain overpayment dust) - - // 3. Check if there was any MOET overpayment returned - // Due to rounding UP (UFix128 → UFix64), there may be a tiny overpayment - // that flips to credit and gets returned - if moetBalanceAfter > 0.0 { - log("🔍 Detected MOET overpayment returned: ".concat(moetBalanceAfter.toString())) - log(" This is the rounding-induced overpayment from debt repayment!") - } else { - log("📝 No measurable MOET overpayment at UFix64 precision") - log(" (Overpayment may exist at UFix128 precision but rounds to zero)") - } - - log("✅ Successfully closed position with rounding-based debt repayment") - log("Note: Overpayment from rounding UP debt (UFix128→UFix64) should flip to credit") - log(" and be returned. At UFix64 precision, this may appear as dust or zero.") -} - -// ============================================================================= -// Test: Multiple rebalances create precision-sensitive debt -// ============================================================================= -access(all) -fun test_closePosition_precisionDebtFromRebalances() { - safeReset() - log("\n=== Test: Close Position with Precision Debt from Multiple Rebalances ===") - - // Setup: price = 1.0 - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - - // Configure token - addSupportedTokenZeroRateCurve( - signer: PROTOCOL_ACCOUNT, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - collateralFactor: 0.8, - borrowFactor: 1.0, - depositRate: 1_000_000.0, - depositCapacityCap: 1_000_000.0 - ) - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 1_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position with 100 FLOW and borrow - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [100.0, FLOW_VAULT_STORAGE_PATH, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - // Note: Multiple rebalances could create complex UFix128 precision scenarios - // but for simplicity, we test with a single position state - - // Get debt after rebalances - let positionDetails = getPositionDetails(pid: UInt64(0), beFailed: false) - var moetDebt: UFix64 = 0.0 - for balance in positionDetails.balances { - if balance.vaultType == Type<@MOET.Vault>() && balance.direction == FlowALPv0.BalanceDirection.Debit { - moetDebt = balance.balance - log("MOET debt after rebalances (rounded UP): ".concat(moetDebt.toString())) - } - } - - // Get balances before close - let moetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - let flowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - - // Close position - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(0)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - // Get balances after close - let moetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - let flowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - - log("MOET before: ".concat(moetBefore.toString()).concat(", after: ").concat(moetAfter.toString())) - log("Flow before: ".concat(flowBefore.toString()).concat(", after: ").concat(flowAfter.toString())) - - // Should get back Flow collateral - Test.assert(flowAfter > flowBefore, message: "Should receive Flow collateral back") - - log("✅ Position closed successfully after multiple rebalances") -} From 901a226ae20633fb4f9a43af5d505cc99fe9b8f1 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:53:36 -0500 Subject: [PATCH 44/56] tweak tests --- .../tests/close_position_precision_test.cdc | 102 ++++--- ...close_position_queued_overpayment_test.cdc | 269 +----------------- cadence/transactions/moet/transfer_moet.cdc | 12 + 3 files changed, 68 insertions(+), 315 deletions(-) create mode 100644 cadence/transactions/moet/transfer_moet.cdc diff --git a/cadence/tests/close_position_precision_test.cdc b/cadence/tests/close_position_precision_test.cdc index 15a25b66..c0b78dca 100644 --- a/cadence/tests/close_position_precision_test.cdc +++ b/cadence/tests/close_position_precision_test.cdc @@ -54,9 +54,6 @@ fun test_closePosition_noDebt() { let moetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! Test.assertEqual(0.0, moetBalance) - // Mint tiny buffer to handle any precision shortfall - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) - // Close position (ID 0) let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", @@ -97,9 +94,6 @@ fun test_closePosition_withDebt() { log("Borrowed MOET: \(moetBalance)") Test.assert(moetBalance > 0.0) - // Mint tiny buffer to handle any precision shortfall - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) - // Close position (ID 1 since test 1 created position 0) let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", @@ -146,9 +140,6 @@ fun test_closePosition_afterPriceIncrease() { log("Health after price increase: \(detailsAfter.health)") Test.assert(detailsAfter.health > detailsBefore.health) - // Mint tiny buffer to handle any precision shortfall - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) - // Close position let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", @@ -195,9 +186,6 @@ fun test_closePosition_afterPriceDecrease() { log("Health after price decrease: \(detailsAfter.health)") Test.assert(detailsAfter.health < detailsBefore.health) - // Mint tiny buffer to handle any precision shortfall - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) - // Close position (should still succeed) let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", @@ -255,9 +243,6 @@ fun test_closePosition_precisionShortfall_multipleRebalances() { log("Health: \(finalDetails.health)") logBalances(finalDetails.balances) - // Mint tiny buffer to handle any precision shortfall - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) - // Close position - may have tiny shortfall due to accumulated rounding let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", @@ -315,9 +300,6 @@ fun test_closePosition_extremeVolatility() { log("\n--- Closing after extreme volatility ---") - // Mint larger buffer for extreme volatility test (accumulated errors from 7 rebalances) - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 1.0, beFailed: false) - // Close position let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", @@ -356,9 +338,6 @@ fun test_closePosition_minimalDebt() { let moetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! log("Minimal debt amount: \(moetBalance) MOET") - // Mint tiny buffer to handle any precision shortfall - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) - // Close position let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", @@ -371,39 +350,58 @@ fun test_closePosition_minimalDebt() { } // ============================================================================= -// Test 8: Demonstrate UFix64 precision limits +// Test 8: Close position with insufficient debt repayment // ============================================================================= access(all) -fun test_precision_demonstration() { - log("\n=== UFix64/UFix128 Precision Demonstration ===") - - // Demonstrate UFix64 precision (8 decimal places) - let value1: UFix64 = 1.00000001 - let value2: UFix64 = 1.00000002 - log("UFix64 minimum precision: 0.00000001") - log("Value 1: \(value1)") - log("Value 2: \(value2)") - log("Difference: \(value2 - value1)") - - // Demonstrate UFix128 intermediate precision - let uintValue1 = UFix128(1.23456789) - let uintValue2 = UFix128(9.87654321) - let product = uintValue1 * uintValue2 - log("\nUFix128 calculation: \(uintValue1) * \(uintValue2) = \(product)") - - // Demonstrate precision loss when converting UFix128 → UFix64 - let rounded = FlowALPMath.toUFix64Round(product) - let roundedUp = FlowALPMath.toUFix64RoundUp(product) - let roundedDown = FlowALPMath.toUFix64RoundDown(product) - log("Converting \(product) to UFix64:") - log(" Round (nearest): \(rounded)") - log(" Round Up: \(roundedUp)") - log(" Round Down: \(roundedDown)") - log(" Precision loss range: \(roundedUp - roundedDown)") - - log("\n✅ Precision demonstration complete") - log("Key insight: Each UFix128→UFix64 conversion loses up to 0.00000001") - log("Multiple operations accumulate this loss, requiring shortfall tolerance") +fun test_closePosition_insufficientRepayment() { + log("\n=== Test: Close Position with Insufficient Debt Repayment ===") + + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with debt — borrowed MOET is pushed to user's MOET vault (position 7) + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [100.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + let debt = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + log("Borrowed MOET (= debt): \(debt)") + Test.assert(debt > 0.0) + + let shortfall = 0.00000001 + + // Transfer a tiny amount away so user has (debt - 1 satoshi), one short of what's needed + let other = Test.createAccount() + setupMoetVault(other, beFailed: false) + let transferTx = Test.Transaction( + code: Test.readFile("../transactions/moet/transfer_moet.cdc"), + authorizers: [user.address], + signers: [user], + arguments: [other.address, shortfall] + ) + let transferRes = Test.executeTransaction(transferTx) + Test.expect(transferRes, Test.beSucceeded()) + + let remainingMoet = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + log("MOET remaining after transfer: \(remainingMoet)") + Test.assertEqual(debt - shortfall, remainingMoet) + + // Attempt to close — source has 0 MOET but debt requires repayment + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(7)], + user + ) + Test.expect(closeRes, Test.beFailed()) + Test.assertError(closeRes, errorMessage: "Insufficient funds from source") + log("✅ Close correctly failed with insufficient repayment") } // ============================================================================= diff --git a/cadence/tests/close_position_queued_overpayment_test.cdc b/cadence/tests/close_position_queued_overpayment_test.cdc index 0ae20179..346d9b8b 100644 --- a/cadence/tests/close_position_queued_overpayment_test.cdc +++ b/cadence/tests/close_position_queued_overpayment_test.cdc @@ -15,21 +15,10 @@ import "test_helpers.cdc" // 2. Overpayment during debt repayment that becomes collateral // ----------------------------------------------------------------------------- -access(all) var snapshot: UInt64 = 0 - -access(all) -fun safeReset() { - let cur = getCurrentBlockHeight() - if cur > snapshot { - Test.reset(to: snapshot) - } -} - access(all) fun setup() { deployContracts() createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) - snapshot = getCurrentBlockHeight() } // ============================================================================= @@ -37,7 +26,6 @@ fun setup() { // ============================================================================= access(all) fun test_closePosition_withQueuedDeposits() { - safeReset() log("\n=== Test: Close Position with Queued Deposits ===") // Setup: price = 1.0 @@ -86,8 +74,9 @@ fun test_closePosition_withQueuedDeposits() { // The position can only hold 100 FLOW max, so ~100 FLOW should be queued // User should have ~9800 FLOW (10000 - 50 - 150) let expectedAfterDeposit = 10_000.0 - 50.0 - 150.0 - Test.assert(flowBalanceAfterDeposit >= expectedAfterDeposit - 1.0, message: "Should have withdrawn full deposit amount") - Test.assert(flowBalanceAfterDeposit <= expectedAfterDeposit + 1.0, message: "Should have withdrawn full deposit amount") + equalWithinVariance(flowBalanceAfterDeposit, expectedAfterDeposit) + // Test.assert(flowBalanceAfterDeposit >= expectedAfterDeposit - 1.0, message: "Should have withdrawn full deposit amount") + // Test.assert(flowBalanceAfterDeposit <= expectedAfterDeposit + 1.0, message: "Should have withdrawn full deposit amount") // Mint MOET for closing (tiny buffer for any precision) mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) @@ -118,256 +107,10 @@ fun test_closePosition_withQueuedDeposits() { // Started: 10000, Withdrew: 200, Should get back: 200 // Final: 10000 let expectedFinal = 10_000.0 // All deposits returned - Test.assert(flowBalanceAfter >= expectedFinal - 10.0, message: "Should return all deposits (processed + queued)") - Test.assert(flowBalanceAfter <= expectedFinal + 10.0, message: "Should return all deposits (processed + queued)") + equalWithinVariance(flowBalanceAfter, expectedFinal) + // Test.assert(flowBalanceAfter >= expectedFinal - 10.0, message: "Should return all deposits (processed + queued)") + // Test.assert(flowBalanceAfter <= expectedFinal + 10.0, message: "Should return all deposits (processed + queued)") log("✅ Successfully closed position with queued deposits returned") } -// ============================================================================= -// Test 2: Close position with overpayment -// ============================================================================= -access(all) -fun test_closePosition_withOverpayment() { - safeReset() - log("\n=== Test: Close Position with Overpayment ===") - - // Setup: price = 1.0 - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - - // Configure token with high limits - addSupportedTokenZeroRateCurve( - signer: PROTOCOL_ACCOUNT, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - collateralFactor: 0.8, - borrowFactor: 1.0, - depositRate: 1_000_000.0, - depositCapacityCap: 1_000_000.0 - ) - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 1_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position with 100 FLOW and borrow MOET - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [100.0, FLOW_VAULT_STORAGE_PATH, true], // pushToDrawDownSink = true to borrow - user - ) - Test.expect(openRes, Test.beSucceeded()) - - // Check MOET debt - let positionDetailsBefore = getPositionDetails(pid: UInt64(0), beFailed: false) - let debtBefore = positionDetailsBefore.balances[0].balance - log("Initial MOET debt: ".concat(debtBefore.toString())) - - // Verify there's debt - Test.assert(debtBefore > 0.0, message: "Position should have debt") - - // Get initial MOET balance - let moetBalanceBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - log("MOET balance before close: ".concat(moetBalanceBefore.toString())) - - // Mint extra MOET (overpayment) - let overpaymentAmount = 10.0 - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: overpaymentAmount, beFailed: false) - - let moetBalanceWithExtra = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - log("MOET balance with overpayment: ".concat(moetBalanceWithExtra.toString())) - - // Close position with overpayment - // The closePosition should: - // 1. Pull exact debt amount from MOET vault - // 2. Any extra pulled becomes credit balance - // 3. Return all credits (Flow collateral + MOET overpayment) - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(0)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - // Get final balances - let flowBalanceAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - let moetBalanceAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - - log("Flow balance after close: ".concat(flowBalanceAfter.toString())) - log("MOET balance after close: ".concat(moetBalanceAfter.toString())) - - // User started with 1000 FLOW, deposited 100, should get back ~100 - // Final balance should be close to 1000 FLOW - Test.assert(flowBalanceAfter >= 990.0, message: "Should have at least 990 FLOW total") - Test.assert(flowBalanceAfter <= 1010.0, message: "Should have at most 1010 FLOW total") - - // MOET balance should be approximately: (initial + overpayment - debt) - // Since overpayment > needed, some MOET should remain - // The contract pulls exactly what's needed, so any overpayment in the vault stays there - // But if overpayment was deposited and became credit, it should be returned - log("MOET returned/remaining: ".concat(moetBalanceAfter.toString())) - - log("✅ Successfully closed position with overpayment handled correctly") -} - -// ============================================================================= -// Test 3: Close position with both queued deposits and overpayment -// ============================================================================= -access(all) -fun test_closePosition_withQueuedAndOverpayment() { - safeReset() - log("\n=== Test: Close Position with Queued Deposits AND Overpayment ===") - - // Setup: price = 1.0 - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - - // Configure token with moderate deposit limit - addSupportedTokenZeroRateCurve( - signer: PROTOCOL_ACCOUNT, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - collateralFactor: 0.8, - borrowFactor: 1.0, - depositRate: 150.0, // Moderate limit - depositCapacityCap: 150.0 - ) - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 10_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position with 100 FLOW and borrow - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [100.0, FLOW_VAULT_STORAGE_PATH, true], // Borrow MOET - user - ) - Test.expect(openRes, Test.beSucceeded()) - - // Get debt amount - let positionDetails1 = getPositionDetails(pid: UInt64(0), beFailed: false) - let debt = positionDetails1.balances[0].balance - log("MOET debt: ".concat(debt.toString())) - - // Try to deposit more Flow (should partially queue since limit is 150) - let depositRes = _executeTransaction( - "./transactions/position/deposit_to_position_by_id.cdc", - [UInt64(0), 100.0, FLOW_VAULT_STORAGE_PATH, false], - user - ) - Test.expect(depositRes, Test.beSucceeded()) - - // Get balances before close - let flowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - let moetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - - log("Flow before close: ".concat(flowBefore.toString())) - log("MOET before close: ".concat(moetBefore.toString())) - - // Mint extra MOET for overpayment - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 5.0, beFailed: false) - - // Close position - should return: - // 1. Processed Flow collateral - // 2. Queued Flow deposits (if any) - // 3. Any MOET overpayment (if it becomes credit) - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(0)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - // Get final balances - let flowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - let moetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - - log("Flow after close: ".concat(flowAfter.toString())) - log("MOET after close: ".concat(moetAfter.toString())) - - // User deposited 100 + 100 = 200 FLOW, with limit 150, so ~50 queued - // Should get back processed collateral + queued - // Final flow should be close to starting (minus any processed that stayed) - let flowReturned = flowAfter - flowBefore - log("Flow returned: ".concat(flowReturned.toString())) - - // Should return collateral + queued deposits - Test.assert(flowReturned >= 140.0, message: "Should return collateral + queued deposits") - Test.assert(flowReturned <= 210.0, message: "Should return collateral + queued deposits") - - log("✅ Successfully closed position with both queued deposits and overpayment") -} - -// ============================================================================= -// Test 4: Verify queued deposits are tracked and returned correctly -// ============================================================================= -access(all) -fun test_queuedDeposits_tracking() { - safeReset() - log("\n=== Test: Queued Deposits Tracking ===") - - // Setup with very low deposit limit - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - addSupportedTokenZeroRateCurve( - signer: PROTOCOL_ACCOUNT, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - collateralFactor: 0.8, - borrowFactor: 1.0, - depositRate: 50.0, // Very low limit - depositCapacityCap: 50.0 - ) - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 10_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position with small amount (within limit) - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [30.0, FLOW_VAULT_STORAGE_PATH, false], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - log("Initial deposit completed") - - // Deposit amount that exceeds limit (30 already in, limit is 50, so deposit 100) - // Should result in: 20 more processed (to hit 50 limit), 80 queued - let depositRes = _executeTransaction( - "./transactions/position/deposit_to_position_by_id.cdc", - [UInt64(0), 100.0, FLOW_VAULT_STORAGE_PATH, false], - user - ) - Test.expect(depositRes, Test.beSucceeded()) - - log("Large deposit completed - queuing should have occurred") - - // Close and verify queued deposits are returned - mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) - - let flowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - log("Flow before close: ".concat(flowBefore.toString())) - - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(0)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - let flowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! - let returned = flowAfter - flowBefore - - log("Flow after close: ".concat(flowAfter.toString())) - log("Total Flow returned: ".concat(returned.toString())) - - // Should return: - // - 50 FLOW processed collateral (30 initial + 20 from second deposit) - // - 80 FLOW queued deposits - // Total: ~130 FLOW - Test.assert(returned >= 125.0, message: "Should return at least 125 FLOW (collateral + queued)") - Test.assert(returned <= 135.0, message: "Should return at most 135 FLOW (collateral + queued)") - - log("✅ Queued deposits tracked and returned correctly") -} diff --git a/cadence/transactions/moet/transfer_moet.cdc b/cadence/transactions/moet/transfer_moet.cdc new file mode 100644 index 00000000..3946b16b --- /dev/null +++ b/cadence/transactions/moet/transfer_moet.cdc @@ -0,0 +1,12 @@ +import MOET from "MOET" +import FungibleToken from "FungibleToken" + +transaction(recipient: Address, amount: UFix64) { + prepare(signer: auth(BorrowValue) &Account) { + let vault = signer.storage.borrow(from: MOET.VaultStoragePath) + ?? panic("Could not borrow MOET vault") + let receiver = getAccount(recipient).capabilities.borrow<&{FungibleToken.Receiver}>(MOET.VaultPublicPath) + ?? panic("Could not borrow MOET receiver") + receiver.deposit(from: <-vault.withdraw(amount: amount)) + } +} From f5de92dad53b6c284504f7bdd46b37d28aa003ae Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:14:43 -0500 Subject: [PATCH 45/56] address comments --- cadence/contracts/FlowALPv0.cdc | 30 +++++++++++--------- cadence/tests/insolvency_redemption_test.cdc | 15 ---------- 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 2723cdae..5316bac4 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3261,25 +3261,25 @@ access(all) contract FlowALPv0 { /// via the DeFiActions.Source abstraction. /// /// Queued Deposits: - /// - Any deposits that were queued but not yet processed are extracted and returned - /// - These are funds that exceeded limits and were waiting for async processing + /// - Any unprocessed queued deposits are extracted and merged into the return array (dedup by type) /// /// Steps: - /// 1. Locks the position - /// 2. Gets all debts from position - /// 3. Pulls from sources to repay debts (overpayment becomes credit balance) - /// 4. Verifies NO debt remains (zero tolerance for unpaid debt) - /// 5. Gets collateral types (after repayment, to include any overpayment credits) - /// 6. Withdraws ALL collateral - /// 7. Extracts queued deposits and merges into collateral map (dedup by type) - /// 8. Builds withdrawals map for event emission - /// 9. Emits PositionClosed event - /// 10. Unlocks position + /// 1. Lock the position + /// 2. Get all debts from position + /// 3. Pull from sources to repay debts (overpayment becomes credit balance) + /// 4. Verify NO debt remains (zero tolerance for unpaid debt) + /// 5. Get collateral types (after repayment, to include any overpayment credits) + /// 6. Withdraw all collateral into a type-keyed map + /// 7. Extract queued deposits and merge into map (same type → deposit into existing vault) + /// 8. Build withdrawals map for event emission + /// 9. Emit PositionClosed event + /// 10. Drain map into return array (one vault per token type, no duplicates) + /// 11. Destroy InternalPosition and unlock /// /// @param pid: Position ID to close /// @param repaymentSources: Array of Sources that can provide funds to repay debts /// Sources are pulled from as needed (supports swapping, multi-vault, etc.) - /// @return Array of vaults containing collateral + queued deposits + any overpayment, one per token type + /// @return Array of vaults — one per token type — containing collateral + queued deposits + any overpayment /// access(EPosition) fun closePosition( pid: UInt64, @@ -3338,13 +3338,15 @@ access(all) contract FlowALPv0 { // Step 9: Emit position closed event self._emitPositionClosedEvent(pid: pid, debtsByType: debtsByType, withdrawalsByType: withdrawalsByType) - // Step 10: Drain map into array and unlock + // Step 10: Drain map into return array (one vault per token type, no duplicates) let returnVaults: @[{FungibleToken.Vault}] <- [] for vaultType in vaults.keys { returnVaults.append(<- vaults.remove(key: vaultType)!) } destroy vaults + // Step 11: Destroy InternalPosition and unlock + destroy self.positions.remove(key: pid)! self._unlockPosition(pid) return <- returnVaults diff --git a/cadence/tests/insolvency_redemption_test.cdc b/cadence/tests/insolvency_redemption_test.cdc index 68873dec..826af0d3 100644 --- a/cadence/tests/insolvency_redemption_test.cdc +++ b/cadence/tests/insolvency_redemption_test.cdc @@ -81,20 +81,5 @@ fun test_borrower_full_redemption_insolvency() { borrower ) Test.expect(closeRes, Test.beSucceeded()) - - // Post-conditions: zero debt, collateral redeemed, HF == ceiling - let detailsAfter = getPositionDetails(pid: pid, beFailed: false) - var postMoetDebt: UFix64 = 0.0 - var postFlowColl: UFix64 = 0.0 - for b in detailsAfter.balances { - if b.vaultType == Type<@MOET.Vault>() && b.direction == FlowALPv0.BalanceDirection.Debit { postMoetDebt = b.balance } - if b.vaultType == Type<@FlowToken.Vault>() && b.direction == FlowALPv0.BalanceDirection.Credit { postFlowColl = b.balance } - } - Test.assertEqual(0.0, postMoetDebt) - Test.assertEqual(0.0, postFlowColl) - - let hFinal = getPositionHealth(pid: pid, beFailed: false) - Test.assertEqual(CEILING_HEALTH, hFinal) } - From 114fa5c601f0377ce6aa6c763b1c21c5cee1db2f Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:15:36 -0500 Subject: [PATCH 46/56] tweak ci/cd --- .github/workflows/cadence_tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cadence_tests.yml b/.github/workflows/cadence_tests.yml index 8be59855..d5d55e9f 100644 --- a/.github/workflows/cadence_tests.yml +++ b/.github/workflows/cadence_tests.yml @@ -5,10 +5,11 @@ on: push: branches: - main + - v0 pull_request: branches: - main - - nialexsan/pre-refactor + - v0 jobs: tests: From 1b3c6fadd8eb4844eb70b23fa61947dbb78dd2a7 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:22:01 -0500 Subject: [PATCH 47/56] rename test --- ...ment_test.cdc => close_position_with_queued_deposits_test.cdc} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cadence/tests/{close_position_queued_overpayment_test.cdc => close_position_with_queued_deposits_test.cdc} (100%) diff --git a/cadence/tests/close_position_queued_overpayment_test.cdc b/cadence/tests/close_position_with_queued_deposits_test.cdc similarity index 100% rename from cadence/tests/close_position_queued_overpayment_test.cdc rename to cadence/tests/close_position_with_queued_deposits_test.cdc From f43e2e0ed928b91ecdff74cf00183bbb041918bf Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:49:59 -0500 Subject: [PATCH 48/56] remove unused test --- .../tests/close_position_precision_test.cdc | 40 +------------------ 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/cadence/tests/close_position_precision_test.cdc b/cadence/tests/close_position_precision_test.cdc index c0b78dca..8a4683d2 100644 --- a/cadence/tests/close_position_precision_test.cdc +++ b/cadence/tests/close_position_precision_test.cdc @@ -312,45 +312,7 @@ fun test_closePosition_extremeVolatility() { } // ============================================================================= -// Test 7: Close with minimal debt (edge case) -// ============================================================================= -access(all) -fun test_closePosition_minimalDebt() { - log("\n=== Test: Close with Minimal Debt ===") - - // Reset price to 1.0 for this test - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - - // Reuse existing pool from previous test - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 1_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position with minimal amount - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [1.0, FLOW_VAULT_STORAGE_PATH, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - let moetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - log("Minimal debt amount: \(moetBalance) MOET") - - // Close position - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(6)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - log("✅ Successfully closed with minimal debt") -} - -// ============================================================================= -// Test 8: Close position with insufficient debt repayment +// Test 7: Close position with insufficient debt repayment // ============================================================================= access(all) fun test_closePosition_insufficientRepayment() { From 253136749a5f0fdc22eb690e146fa083c9c558a7 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:53:36 -0500 Subject: [PATCH 49/56] Apply suggestions from code review --- .../tests/close_position_precision_test.cdc | 98 +------------------ 1 file changed, 3 insertions(+), 95 deletions(-) diff --git a/cadence/tests/close_position_precision_test.cdc b/cadence/tests/close_position_precision_test.cdc index 8a4683d2..0d0651d7 100644 --- a/cadence/tests/close_position_precision_test.cdc +++ b/cadence/tests/close_position_precision_test.cdc @@ -106,99 +106,7 @@ fun test_closePosition_withDebt() { } // ============================================================================= -// Test 3: Close after collateral price increase (balance increases) -// ============================================================================= -access(all) -fun test_closePosition_afterPriceIncrease() { - log("\n=== Test: Close After Collateral Price Increase (Balance Increases) ===") - - // Reset price to 1.0 for this test - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - - // Reuse existing pool from previous test - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 1_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [100.0, FLOW_VAULT_STORAGE_PATH, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - let detailsBefore = getPositionDetails(pid: 2, beFailed: false) - log("Health before price increase: \(detailsBefore.health)") - - // Increase FLOW price to 1.5 (50% gain) - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.5) - log("Increased FLOW price to $1.5 (+50%)") - - let detailsAfter = getPositionDetails(pid: 2, beFailed: false) - log("Health after price increase: \(detailsAfter.health)") - Test.assert(detailsAfter.health > detailsBefore.health) - - // Close position - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(2)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - log("✅ Successfully closed after collateral appreciation (balance increased)") -} - -// ============================================================================= -// Test 4: Close after collateral price decrease (balance falls) -// ============================================================================= -access(all) -fun test_closePosition_afterPriceDecrease() { - log("\n=== Test: Close After Collateral Price Decrease (Balance Falls) ===") - - // Reset price to 1.0 for this test - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - - // Reuse existing pool from previous test - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 1_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [100.0, FLOW_VAULT_STORAGE_PATH, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - let detailsBefore = getPositionDetails(pid: 3, beFailed: false) - log("Health before price decrease: \(detailsBefore.health)") - - // Decrease FLOW price to 0.8 (20% loss) - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.8) - log("Decreased FLOW price to $0.8 (-20%)") - - let detailsAfter = getPositionDetails(pid: 3, beFailed: false) - log("Health after price decrease: \(detailsAfter.health)") - Test.assert(detailsAfter.health < detailsBefore.health) - - // Close position (should still succeed) - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(3)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - log("✅ Successfully closed after collateral depreciation (balance fell)") -} - -// ============================================================================= -// Test 5: Close with precision shortfall after multiple rebalances +// Test 3: Close with precision shortfall after multiple rebalances // ============================================================================= access(all) fun test_closePosition_precisionShortfall_multipleRebalances() { @@ -255,7 +163,7 @@ fun test_closePosition_precisionShortfall_multipleRebalances() { } // ============================================================================= -// Test 6: Demonstrate precision with extreme volatility +// Test 4: Demonstrate precision with extreme volatility // ============================================================================= access(all) fun test_closePosition_extremeVolatility() { @@ -312,7 +220,7 @@ fun test_closePosition_extremeVolatility() { } // ============================================================================= -// Test 7: Close position with insufficient debt repayment +// Test 5: Close position with insufficient debt repayment // ============================================================================= access(all) fun test_closePosition_insufficientRepayment() { From 918c314e2f4220458ad6dbf5bcf7524fd8ca1cc5 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:03:59 -0500 Subject: [PATCH 50/56] Apply suggestions from code review --- cadence/tests/close_position_precision_test.cdc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cadence/tests/close_position_precision_test.cdc b/cadence/tests/close_position_precision_test.cdc index 0d0651d7..2305a932 100644 --- a/cadence/tests/close_position_precision_test.cdc +++ b/cadence/tests/close_position_precision_test.cdc @@ -146,7 +146,7 @@ fun test_closePosition_precisionShortfall_multipleRebalances() { Test.expect(reb3, Test.beSucceeded()) // Get final position state - let finalDetails = getPositionDetails(pid: 4, beFailed: false) + let finalDetails = getPositionDetails(pid: 2, beFailed: false) log("\n--- Final State ---") log("Health: \(finalDetails.health)") logBalances(finalDetails.balances) @@ -201,7 +201,7 @@ fun test_closePosition_extremeVolatility() { ) Test.expect(rebalanceRes, Test.beSucceeded()) - let details = getPositionDetails(pid: 5, beFailed: false) + let details = getPositionDetails(pid: 3, beFailed: false) log("Health: \(details.health)") volCount = volCount + 1 } From 434e17718dd2f491b2b264e2e0aa084b5c5ae702 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Wed, 4 Mar 2026 18:21:06 -0400 Subject: [PATCH 51/56] Fix stale async queue entries on close and add regression test --- cadence/contracts/FlowALPv0.cdc | 26 +++++- .../close_position_async_queue_stale_test.cdc | 85 +++++++++++++++++++ .../pool-management/async_update_all.cdc | 14 +++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 cadence/tests/close_position_async_queue_stale_test.cdc create mode 100644 cadence/tests/transactions/flow-alp/pool-management/async_update_all.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 5316bac4..eb0a7d9a 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3345,7 +3345,8 @@ access(all) contract FlowALPv0 { } destroy vaults - // Step 11: Destroy InternalPosition and unlock + // Step 11: Remove stale queue entry, then destroy InternalPosition and unlock + self._removePositionFromUpdateQueue(pid: pid) destroy self.positions.remove(key: pid)! self._unlockPosition(pid) @@ -3828,6 +3829,12 @@ access(all) contract FlowALPv0 { var processed: UInt64 = 0 while self.positionsNeedingUpdates.length > 0 && processed < self.positionsProcessedPerCallback { let pid = self.positionsNeedingUpdates.removeFirst() + if self.positions[pid] == nil { + // Stale queue entry: position may have been closed and removed from self.positions. + // Skip to keep async updates progressing for the remaining queue entries. + processed = processed + 1 + continue + } self.asyncUpdatePosition(pid: pid) self._queuePositionForUpdateIfNecessary(pid: pid) processed = processed + 1 @@ -3945,6 +3952,23 @@ access(all) contract FlowALPv0 { } } + /// Removes a position from the async update queue. + /// This is needed when closing a position to prevent stale queue entries. + access(self) fun _removePositionFromUpdateQueue(pid: UInt64) { + if !self.positionsNeedingUpdates.contains(pid) { + return + } + + let remaining: [UInt64] = [] + while self.positionsNeedingUpdates.length > 0 { + let queuedPid = self.positionsNeedingUpdates.removeFirst() + if queuedPid != pid { + remaining.append(queuedPid) + } + } + self.positionsNeedingUpdates = remaining + } + /// Returns a position's BalanceSheet containing its effective collateral and debt as well as its current health /// TODO(jord): in all cases callers already are calling _borrowPosition, more efficient to pass in PositionView? access(self) fun _getUpdatedBalanceSheet(pid: UInt64): BalanceSheet { diff --git a/cadence/tests/close_position_async_queue_stale_test.cdc b/cadence/tests/close_position_async_queue_stale_test.cdc new file mode 100644 index 00000000..60770ed0 --- /dev/null +++ b/cadence/tests/close_position_async_queue_stale_test.cdc @@ -0,0 +1,85 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowALPv0" +import "test_helpers.cdc" + +access(all) +fun setup() { + deployContracts() + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) +} + +access(all) +fun test_closePosition_clearsQueuedAsyncUpdateEntry() { + // Regression target: + // A position could remain in `positionsNeedingUpdates` after being closed. + // Then `asyncUpdate()` would pop that stale pid and panic when trying to + // update a position that no longer exists. + // + // This test recreates that exact sequence and asserts async callbacks + // succeed after close. + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Keep deposit capacity low so new deposits can overflow active capacity and + // be queued for async processing (which queues the position id as well). + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 100.0, + depositCapacityCap: 100.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Step 1: Open a position with a small initial deposit. + // This consumes part of the token's active capacity. + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [50.0, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Step 2: Deposit an amount that exceeds remaining active capacity. + // The overflow is queued, and the position is put in the async update queue. + let depositRes = _executeTransaction( + "./transactions/position/deposit_to_position_by_id.cdc", + [UInt64(0), 150.0, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(depositRes, Test.beSucceeded()) + + // Step 3: Close the position before async callbacks drain the queue. + // This is the key condition that previously left a stale pid behind. + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + // Step 4 (regression assertion): run async update callback. + // Before the fix, this could panic when touching a removed position. + // After the fix, stale entries are removed/skipped and callback succeeds. + let asyncRes = _executeTransaction( + "./transactions/flow-alp/pool-management/async_update_all.cdc", + [], + PROTOCOL_ACCOUNT + ) + Test.expect(asyncRes, Test.beSucceeded()) + + // Step 5: run one more callback to prove queue state remains clean. + let asyncRes2 = _executeTransaction( + "./transactions/flow-alp/pool-management/async_update_all.cdc", + [], + PROTOCOL_ACCOUNT + ) + Test.expect(asyncRes2, Test.beSucceeded()) +} diff --git a/cadence/tests/transactions/flow-alp/pool-management/async_update_all.cdc b/cadence/tests/transactions/flow-alp/pool-management/async_update_all.cdc new file mode 100644 index 00000000..362d54dd --- /dev/null +++ b/cadence/tests/transactions/flow-alp/pool-management/async_update_all.cdc @@ -0,0 +1,14 @@ +import "FlowALPv0" + +transaction { + let pool: auth(FlowALPv0.EImplementation) &FlowALPv0.Pool + + prepare(signer: auth(BorrowValue) &Account) { + self.pool = signer.storage.borrow(from: FlowALPv0.PoolStoragePath) + ?? panic("Could not borrow Pool") + } + + execute { + self.pool.asyncUpdate() + } +} From cb17a21050cdb4f8c529d86c3b57b2a80e73f9e9 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Wed, 4 Mar 2026 18:31:02 -0400 Subject: [PATCH 52/56] Optimize closePosition queue removal to linear scan --- cadence/contracts/FlowALPv0.cdc | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index eb0a7d9a..bed61220 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -3955,18 +3955,16 @@ access(all) contract FlowALPv0 { /// Removes a position from the async update queue. /// This is needed when closing a position to prevent stale queue entries. access(self) fun _removePositionFromUpdateQueue(pid: UInt64) { - if !self.positionsNeedingUpdates.contains(pid) { - return - } - - let remaining: [UInt64] = [] - while self.positionsNeedingUpdates.length > 0 { - let queuedPid = self.positionsNeedingUpdates.removeFirst() - if queuedPid != pid { - remaining.append(queuedPid) + // Keep this operation linear-time: + // find first matching pid, then remove once while preserving queue order. + var i = 0 + while i < self.positionsNeedingUpdates.length { + if self.positionsNeedingUpdates[i] == pid { + self.positionsNeedingUpdates.remove(at: i) + return } + i = i + 1 } - self.positionsNeedingUpdates = remaining } /// Returns a position's BalanceSheet containing its effective collateral and debt as well as its current health From a1fbfb11e6af443caa2769286759d78f68be9368 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:31:08 -0500 Subject: [PATCH 53/56] Apply suggestions from code review --- cadence/tests/close_position_precision_test.cdc | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cadence/tests/close_position_precision_test.cdc b/cadence/tests/close_position_precision_test.cdc index 2305a932..d0491619 100644 --- a/cadence/tests/close_position_precision_test.cdc +++ b/cadence/tests/close_position_precision_test.cdc @@ -132,17 +132,17 @@ fun test_closePosition_precisionShortfall_multipleRebalances() { // Perform rebalances with varying prices to accumulate rounding errors log("\nRebalance 1: FLOW price = $1.2") setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.2) - let reb1 = _executeTransaction("../transactions/flow-alp/pool-management/rebalance_position.cdc", [UInt64(4), true], PROTOCOL_ACCOUNT) + let reb1 = _executeTransaction("../transactions/flow-alp/pool-management/rebalance_position.cdc", [UInt64(2), true], PROTOCOL_ACCOUNT) Test.expect(reb1, Test.beSucceeded()) log("\nRebalance 2: FLOW price = $1.9") setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.9) - let reb2 = _executeTransaction("../transactions/flow-alp/pool-management/rebalance_position.cdc", [UInt64(4), true], PROTOCOL_ACCOUNT) + let reb2 = _executeTransaction("../transactions/flow-alp/pool-management/rebalance_position.cdc", [UInt64(2), true], PROTOCOL_ACCOUNT) Test.expect(reb2, Test.beSucceeded()) log("\nRebalance 3: FLOW price = $1.5") setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.5) - let reb3 = _executeTransaction("../transactions/flow-alp/pool-management/rebalance_position.cdc", [UInt64(4), true], PROTOCOL_ACCOUNT) + let reb3 = _executeTransaction("../transactions/flow-alp/pool-management/rebalance_position.cdc", [UInt64(2), true], PROTOCOL_ACCOUNT) Test.expect(reb3, Test.beSucceeded()) // Get final position state @@ -154,7 +154,7 @@ fun test_closePosition_precisionShortfall_multipleRebalances() { // Close position - may have tiny shortfall due to accumulated rounding let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(4)], + [UInt64(2)], user ) Test.expect(closeRes, Test.beSucceeded()) @@ -196,7 +196,7 @@ fun test_closePosition_extremeVolatility() { let rebalanceRes = _executeTransaction( "../transactions/flow-alp/pool-management/rebalance_position.cdc", - [UInt64(5), true], + [UInt64(3), true], PROTOCOL_ACCOUNT ) Test.expect(rebalanceRes, Test.beSucceeded()) @@ -211,7 +211,7 @@ fun test_closePosition_extremeVolatility() { // Close position let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(5)], + [UInt64(3)], user ) Test.expect(closeRes, Test.beSucceeded()) @@ -266,7 +266,7 @@ fun test_closePosition_insufficientRepayment() { // Attempt to close — source has 0 MOET but debt requires repayment let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(7)], + [UInt64(4)], user ) Test.expect(closeRes, Test.beFailed()) From 6dafb4ec008c0b8215286a7a40c8af47d241cc84 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Wed, 4 Mar 2026 18:59:39 -0400 Subject: [PATCH 54/56] Remove closed Position resource from manager after close --- .../flow-alp/position/repay_and_close_position.cdc | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cadence/transactions/flow-alp/position/repay_and_close_position.cdc b/cadence/transactions/flow-alp/position/repay_and_close_position.cdc index 152de97e..c1441357 100644 --- a/cadence/transactions/flow-alp/position/repay_and_close_position.cdc +++ b/cadence/transactions/flow-alp/position/repay_and_close_position.cdc @@ -4,6 +4,7 @@ // 1. Creates a VaultSource from the user's MOET vault capability // 2. closePosition pulls exactly what it needs from the source // 3. Returns all collateral + any overpayment +// 4. Removes/destroys the closed Position resource from PositionManager // // Benefits: // - No debt precalculation needed in transaction @@ -20,6 +21,7 @@ import "MOET" transaction(positionId: UInt64) { + let manager: auth(FungibleToken.Withdraw, FlowALPv0.EPositionAdmin) &FlowALPv0.PositionManager let position: auth(FungibleToken.Withdraw) &FlowALPv0.Position let flowReceiverRef: &{FungibleToken.Receiver} let moetReceiverRef: &{FungibleToken.Receiver} @@ -27,12 +29,12 @@ transaction(positionId: UInt64) { prepare(borrower: auth(BorrowValue, Capabilities) &Account) { // Borrow the PositionManager from constant storage path with both required entitlements - let manager = borrower.storage.borrow( + self.manager = borrower.storage.borrow( from: FlowALPv0.PositionStoragePath ) ?? panic("Could not find PositionManager in storage") // Borrow the position with withdraw entitlement - self.position = manager.borrowAuthorizedPosition(pid: positionId) as! auth(FungibleToken.Withdraw) &FlowALPv0.Position + self.position = self.manager.borrowAuthorizedPosition(pid: positionId) as! auth(FungibleToken.Withdraw) &FlowALPv0.Position // Get receiver references for depositing withdrawn collateral and overpayment self.flowReceiverRef = borrower.capabilities.borrow<&{FungibleToken.Receiver}>( @@ -78,5 +80,10 @@ transaction(positionId: UInt64) { } } destroy returnedVaults + + // Remove and destroy the closed position resource from the manager so stale + // capabilities/resources are not left behind after close. + let closedPosition <- self.manager.removePosition(pid: positionId) + destroy closedPosition } } From ce96de454e24968dd8920e41761dcbf7cff771e7 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:42:35 -0500 Subject: [PATCH 55/56] address comments --- .../close_position_async_queue_stale_test.cdc | 85 ------------------- .../tests/close_position_precision_test.cdc | 20 ++--- ...ose_position_with_queued_deposits_test.cdc | 74 +++++++++++++++- cadence/tests/test_helpers.cdc | 7 ++ .../position/deposit_to_position_by_id.cdc | 4 +- 5 files changed, 88 insertions(+), 102 deletions(-) delete mode 100644 cadence/tests/close_position_async_queue_stale_test.cdc diff --git a/cadence/tests/close_position_async_queue_stale_test.cdc b/cadence/tests/close_position_async_queue_stale_test.cdc deleted file mode 100644 index 60770ed0..00000000 --- a/cadence/tests/close_position_async_queue_stale_test.cdc +++ /dev/null @@ -1,85 +0,0 @@ -import Test -import BlockchainHelpers - -import "MOET" -import "FlowALPv0" -import "test_helpers.cdc" - -access(all) -fun setup() { - deployContracts() - createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) -} - -access(all) -fun test_closePosition_clearsQueuedAsyncUpdateEntry() { - // Regression target: - // A position could remain in `positionsNeedingUpdates` after being closed. - // Then `asyncUpdate()` would pop that stale pid and panic when trying to - // update a position that no longer exists. - // - // This test recreates that exact sequence and asserts async callbacks - // succeed after close. - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - - // Keep deposit capacity low so new deposits can overflow active capacity and - // be queued for async processing (which queues the position id as well). - addSupportedTokenZeroRateCurve( - signer: PROTOCOL_ACCOUNT, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - collateralFactor: 0.8, - borrowFactor: 1.0, - depositRate: 100.0, - depositCapacityCap: 100.0 - ) - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - mintFlow(to: user, amount: 1_000.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Step 1: Open a position with a small initial deposit. - // This consumes part of the token's active capacity. - let openRes = _executeTransaction( - "../transactions/flow-alp/position/create_position.cdc", - [50.0, FLOW_VAULT_STORAGE_PATH, false], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - // Step 2: Deposit an amount that exceeds remaining active capacity. - // The overflow is queued, and the position is put in the async update queue. - let depositRes = _executeTransaction( - "./transactions/position/deposit_to_position_by_id.cdc", - [UInt64(0), 150.0, FLOW_VAULT_STORAGE_PATH, false], - user - ) - Test.expect(depositRes, Test.beSucceeded()) - - // Step 3: Close the position before async callbacks drain the queue. - // This is the key condition that previously left a stale pid behind. - let closeRes = _executeTransaction( - "../transactions/flow-alp/position/repay_and_close_position.cdc", - [UInt64(0)], - user - ) - Test.expect(closeRes, Test.beSucceeded()) - - // Step 4 (regression assertion): run async update callback. - // Before the fix, this could panic when touching a removed position. - // After the fix, stale entries are removed/skipped and callback succeeds. - let asyncRes = _executeTransaction( - "./transactions/flow-alp/pool-management/async_update_all.cdc", - [], - PROTOCOL_ACCOUNT - ) - Test.expect(asyncRes, Test.beSucceeded()) - - // Step 5: run one more callback to prove queue state remains clean. - let asyncRes2 = _executeTransaction( - "./transactions/flow-alp/pool-management/async_update_all.cdc", - [], - PROTOCOL_ACCOUNT - ) - Test.expect(asyncRes2, Test.beSucceeded()) -} diff --git a/cadence/tests/close_position_precision_test.cdc b/cadence/tests/close_position_precision_test.cdc index d0491619..4a78626f 100644 --- a/cadence/tests/close_position_precision_test.cdc +++ b/cadence/tests/close_position_precision_test.cdc @@ -94,6 +94,11 @@ fun test_closePosition_withDebt() { log("Borrowed MOET: \(moetBalance)") Test.assert(moetBalance > 0.0) + // Verify FLOW collateral was deposited + let flowBalanceAfterDeposit = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("FLOW balance after deposit: \(flowBalanceAfterDeposit)") + Test.assert(flowBalanceAfterDeposit < 1_000.0) + // Close position (ID 1 since test 1 created position 0) let closeRes = _executeTransaction( "../transactions/flow-alp/position/repay_and_close_position.cdc", @@ -102,6 +107,11 @@ fun test_closePosition_withDebt() { ) Test.expect(closeRes, Test.beSucceeded()) + // Verify FLOW collateral was returned + let flowBalanceAfterPositionClosed = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + log("FLOW balance after position closed: \(flowBalanceAfterPositionClosed)") + Test.assert(flowBalanceAfterPositionClosed == 1_000.0) + log("✅ Successfully closed position with debt: \(moetBalance) MOET") } @@ -274,13 +284,3 @@ fun test_closePosition_insufficientRepayment() { log("✅ Close correctly failed with insufficient repayment") } -// ============================================================================= -// Helper Functions -// ============================================================================= - -access(all) fun logBalances(_ balances: [FlowALPv0.PositionBalance]) { - for balance in balances { - let direction = balance.direction == FlowALPv0.BalanceDirection.Credit ? "Credit" : "Debit" - log(" \(direction): \(balance.balance) of \(balance.vaultType.identifier)") - } -} diff --git a/cadence/tests/close_position_with_queued_deposits_test.cdc b/cadence/tests/close_position_with_queued_deposits_test.cdc index 346d9b8b..09bc84d4 100644 --- a/cadence/tests/close_position_with_queued_deposits_test.cdc +++ b/cadence/tests/close_position_with_queued_deposits_test.cdc @@ -7,6 +7,15 @@ import "FlowALPv0" import "FlowALPMath" import "test_helpers.cdc" +access(all) var snapshot: UInt64 = 0 + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} // ----------------------------------------------------------------------------- // Close Position: Queued Deposits & Overpayment Test Suite // @@ -19,6 +28,7 @@ access(all) fun setup() { deployContracts() createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + snapshot = getCurrentBlockHeight() } // ============================================================================= @@ -26,6 +36,8 @@ fun setup() { // ============================================================================= access(all) fun test_closePosition_withQueuedDeposits() { + safeReset() + log("\n=== Test: Close Position with Queued Deposits ===") // Setup: price = 1.0 @@ -75,8 +87,6 @@ fun test_closePosition_withQueuedDeposits() { // User should have ~9800 FLOW (10000 - 50 - 150) let expectedAfterDeposit = 10_000.0 - 50.0 - 150.0 equalWithinVariance(flowBalanceAfterDeposit, expectedAfterDeposit) - // Test.assert(flowBalanceAfterDeposit >= expectedAfterDeposit - 1.0, message: "Should have withdrawn full deposit amount") - // Test.assert(flowBalanceAfterDeposit <= expectedAfterDeposit + 1.0, message: "Should have withdrawn full deposit amount") // Mint MOET for closing (tiny buffer for any precision) mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: 0.01, beFailed: false) @@ -108,9 +118,65 @@ fun test_closePosition_withQueuedDeposits() { // Final: 10000 let expectedFinal = 10_000.0 // All deposits returned equalWithinVariance(flowBalanceAfter, expectedFinal) - // Test.assert(flowBalanceAfter >= expectedFinal - 10.0, message: "Should return all deposits (processed + queued)") - // Test.assert(flowBalanceAfter <= expectedFinal + 10.0, message: "Should return all deposits (processed + queued)") log("✅ Successfully closed position with queued deposits returned") } +access(all) +fun test_closePosition_clearsQueuedAsyncUpdateEntry() { + safeReset() + // Regression target: + // A position could remain in `positionsNeedingUpdates` after being closed. + // Then `asyncUpdate()` would pop that stale pid and panic when trying to + // update a position that no longer exists. + // + // This test recreates that exact sequence and asserts async callbacks + // succeed after close. + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + // Keep deposit capacity low so new deposits can overflow active capacity and + // be queued for async processing (which queues the position id as well). + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 100.0, + depositCapacityCap: 100.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Step 1: Open a position with a large initial deposit. + // This consumes full token capacity. + // The overflow is queued, and the position is put in the async update queue. + let openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [200.0, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Step 2: Close the position before async callbacks drain the queue. + // This is the key condition that previously left a stale pid behind. + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(0)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + // Step 3 (regression assertion): run async update callback. + // Before the fix, this could panic when touching a removed position. + // After the fix, stale entries are removed/skipped and callback succeeds. + let asyncRes = _executeTransaction( + "./transactions/flow-alp/pool-management/async_update_all.cdc", + [], + PROTOCOL_ACCOUNT + ) + Test.expect(asyncRes, Test.beSucceeded()) +} + diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 9548c24e..9a2b9fe6 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -834,3 +834,10 @@ fun getCreditBalanceForType(details: FlowALPv0.PositionDetails, vaultType: Type) } return 0.0 } + +access(all) fun logBalances(_ balances: [FlowALPv0.PositionBalance]) { + for balance in balances { + let direction = balance.direction == FlowALPv0.BalanceDirection.Credit ? "Credit" : "Debit" + log(" \(direction): \(balance.balance) of \(balance.vaultType.identifier)") + } +} diff --git a/cadence/tests/transactions/position/deposit_to_position_by_id.cdc b/cadence/tests/transactions/position/deposit_to_position_by_id.cdc index f2641af5..1ebff4e7 100644 --- a/cadence/tests/transactions/position/deposit_to_position_by_id.cdc +++ b/cadence/tests/transactions/position/deposit_to_position_by_id.cdc @@ -7,7 +7,6 @@ transaction(positionId: UInt64, amount: UFix64, vaultStoragePath: StoragePath, p let collateral: @{FungibleToken.Vault} let position: auth(FungibleToken.Withdraw) &FlowALPv0.Position - let pushToDrawDownSink: Bool prepare(signer: auth(BorrowValue) &Account) { // Withdraw the collateral from the signer's stored Vault @@ -22,11 +21,10 @@ transaction(positionId: UInt64, amount: UFix64, vaultStoragePath: StoragePath, p // Borrow the position with withdraw entitlement self.position = manager.borrowAuthorizedPosition(pid: positionId) as! auth(FungibleToken.Withdraw) &FlowALPv0.Position - self.pushToDrawDownSink = pushToDrawDownSink } execute { // Deposit to the position - self.position.depositAndPush(from: <-self.collateral, pushToDrawDownSink: self.pushToDrawDownSink) + self.position.depositAndPush(from: <-self.collateral, pushToDrawDownSink: pushToDrawDownSink) } } From 3f464d70520b5f8be2ef6ff4e92cb1e68326f871 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:54:22 -0500 Subject: [PATCH 56/56] Update cadence/transactions/flow-alp/position/repay_and_close_position.cdc Co-authored-by: Jordan Schalm --- .../transactions/flow-alp/position/repay_and_close_position.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/transactions/flow-alp/position/repay_and_close_position.cdc b/cadence/transactions/flow-alp/position/repay_and_close_position.cdc index c1441357..766d3343 100644 --- a/cadence/transactions/flow-alp/position/repay_and_close_position.cdc +++ b/cadence/transactions/flow-alp/position/repay_and_close_position.cdc @@ -1,4 +1,4 @@ -// Repay debt and close position using Sources (supports swapping, multi-vault, etc.) +// This transaction closes a position, if that position only holds MOET-typed debt balances. // // This transaction uses the closePosition method with Source abstraction: // 1. Creates a VaultSource from the user's MOET vault capability