diff --git a/FlowActions b/FlowActions index 527b2e5b..72540c50 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit 527b2e5b5aac4093ee3dc71ab47ff62bf3283733 +Subproject commit 72540c508f9e33bcf37404a15adf7b1652aeea0a diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 52c40a90..024095a9 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, @@ -1880,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 )) } @@ -3034,6 +3053,181 @@ 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. + /// + /// 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) + /// + /// @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) + /// + access(EPosition) fun closePosition( + pid: UInt64, + 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), repaymentVaults: \(repaymentVaults.length))") + } + + // Step 1: Analyze position to find all debt and collateral types + let positionDetails = self.getPositionDetails(pid: pid) + let debtsByType: {Type: UFix64} = {} + let collateralTypes: [Type] = [] + + 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 + } else if balance.direction == BalanceDirection.Credit { + // Track collateral types (only if they have a balance) + if balance.balance > 0.0 { + collateralTypes.append(balance.vaultType) + } + } + } + + // Step 2: Lock the position for all state modifications + self._lockPosition(pid) + + // Step 3: Process repayment vaults inline + var totalRepaymentValue: UFix64 = 0.0 + let repaymentVaultsLength = repaymentVaults.length + + // 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 + } + } + + // Array is now empty + destroy repaymentVaults + + // 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 { + 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 + } + } + + // Step 5: Withdraw all collateral types + let positionView = self.buildPositionView(pid: pid) + let collateralVaults: @[{FungibleToken.Vault}] <- [] + var totalCollateralValue: UFix64 = 0.0 + + for collateralType in collateralTypes { + let collateralBalance = positionView.trueBalance(ofToken: collateralType) + + if collateralBalance == 0.0 { + // No balance for this collateral type - return empty vault + collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(collateralType)) + 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) + + // 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}?)! + + // 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 + let withdrawn <- reserveVault.withdraw(amount: withdrawAmount) + totalCollateralValue = totalCollateralValue + (withdrawAmount * collateralPrice) + + emit Withdrawn( + pid: pid, + poolUUID: self.uuid, + vaultType: collateralType, + amount: withdrawAmount, + withdrawnUUID: withdrawn.uuid + ) + + collateralVaults.append(<- withdrawn) + } else { + collateralVaults.append(<- DeFiActionsUtils.getEmptyVault(collateralType)) + } + } + + // Emit event for position closure + 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}>(), + finalDebt: totalEffectiveDebt + ) + + // Unlock position now that all operations are complete + self._unlockPosition(pid) + + return <-collateralVaults + } + /////////////////////// // POOL MANAGEMENT /////////////////////// @@ -3852,6 +4046,44 @@ access(all) contract FlowALPv0 { return pool.getPositionDetails(pid: self.id).balances } + /// 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. + /// + /// 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 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 + let debtsByType: {Type: UFix64} = {} + + // Group 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 + } + } + + // 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 + } + /// 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 @@ -3965,6 +4197,28 @@ access(all) contract FlowALPv0 { ) } + /// 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. + /// + /// See Pool.closePosition() for detailed implementation documentation. + /// + /// 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( + repaymentVaults: @[{FungibleToken.Vault}] + ): @[{FungibleToken.Vault}] { + let pool = self.pool.borrow()! + return <- pool.closePosition( + pid: self.id, + repaymentVaults: <-repaymentVaults + ) + } + /// 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. /// @@ -4319,6 +4573,23 @@ 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/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)") + } +} diff --git a/flow.json b/flow.json index 2924d43c..7d8baff7 100644 --- a/flow.json +++ b/flow.json @@ -410,4 +410,4 @@ ] } } -} \ No newline at end of file +}