diff --git a/.github/workflows/cadence_tests.yml b/.github/workflows/cadence_tests.yml index dd67efb5..d5d55e9f 100644 --- a/.github/workflows/cadence_tests.yml +++ b/.github/workflows/cadence_tests.yml @@ -5,9 +5,11 @@ on: push: branches: - main + - v0 pull_request: branches: - main + - v0 jobs: tests: diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 52c40a90..bed61220 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. + /// + /// 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, + 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( pid: UInt64, poolUUID: UInt64, @@ -305,16 +317,24 @@ 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 - self.scaledBalance = FlowALPv0.trueBalanceToScaledBalance( - updatedBalance, - interestIndex: tokenState.debitInterestIndex - ) + // 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 + } else { + self.scaledBalance = FlowALPv0.trueBalanceToScaledBalance( + updatedBalance, + interestIndex: tokenState.debitInterestIndex + ) + } // Decrease the total debit balance for the token tokenState.decreaseDebitBalance(by: amount) @@ -1880,10 +1900,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 )) } @@ -2728,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 @@ -3034,6 +3065,294 @@ access(all) contract FlowALPv0 { return <- withdrawn } + /// Extracts all queued deposits from a position + /// 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" + } + post { + self.positionLock[pid] == true: "Position is not locked" + } + let position = self._borrowPosition(pid: pid) + let queuedVaults: @{Type: {FungibleToken.Vault}} <- {} + + let queuedTypes = position.queuedDeposits.keys + for queuedType in queuedTypes { + let queuedVault <- position.queuedDeposits.remove(key: queuedType)! + queuedVaults[queuedType] <-! 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} { + let positionDetails = self.getPositionDetails(pid: pid) + let debtsByType: {Type: UFix64} = {} + + for balance in positionDetails.balances { + if balance.direction == BalanceDirection.Debit { + let debtType = balance.vaultType + // 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)" + ) + debtsByType[debtType] = balance.balance + } + } + + 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)" + ) + collateralTypes.append(balance.vaultType) + } + } + + return collateralTypes + } + + /// 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}, + sources: [{DeFiActions.Source}] + ) { + pre { + self.positionLock[pid] == true: "Position is not locked" + } + post { + self.positionLock[pid] == true: "Position is not locked" + } + + // Build source map and validate no duplicates + let sourcesByType: {Type: {DeFiActions.Source}} = {} + for source in sources { + let sourceType = source.getSourceType() + if sourcesByType[sourceType] != nil { + panic("Multiple sources provided for debt type: \(sourceType.identifier)") + } + sourcesByType[sourceType] = source + } + + // Repay each debt: find source, validate, and pay + for debtType in debtsByType.keys { + let debtAmount = debtsByType[debtType]! + let source = sourcesByType[debtType] ?? panic("No repayment source provided for debt type: \(debtType.identifier)") + + 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())") + + self._depositEffectsOnly(pid: pid, from: <-pulled) + } + } + + /// 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) + // 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.") + } + } + } + + /// Withdraws all collateral from the position. + /// + /// Returns a map of vault type to vault, guaranteeing no duplicate types. + access(self) fun _withdrawAllCollateral( + pid: UInt64, + collateralTypes: [Type] + ): @{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: @{Type: {FungibleToken.Vault}} <- {} + + // Withdraw all credit balances + for withdrawalType in collateralTypes { + let tokenBalance = positionView.trueBalance(ofToken: withdrawalType) + let withdrawAmount = FlowALPMath.toUFix64RoundDown(tokenBalance) + + if withdrawAmount == 0.0 { + continue + } + + let position = self._borrowPosition(pid: pid) + let tokenState = self._borrowUpdatedTokenState(type: withdrawalType) + let reserveVault = (&self.reserves[withdrawalType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! + + position.balances[withdrawalType]!.recordWithdrawal( + amount: UFix128(withdrawAmount), + tokenState: tokenState + ) + + let withdrawn <- reserveVault.withdraw(amount: withdrawAmount) + + emit Withdrawn( + pid: pid, + poolUUID: self.uuid, + vaultType: withdrawalType, + amount: withdrawAmount, + withdrawnUUID: withdrawn.uuid + ) + + collateralVaults[withdrawalType] <-! 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} = {} + for debtType in debtsByType.keys { + repaymentsEvent[debtType.identifier] = debtsByType[debtType]! + } + + let withdrawalsEvent: {String: UFix64} = {} + for withdrawalType in withdrawalsByType.keys { + withdrawalsEvent[withdrawalType.identifier] = withdrawalsByType[withdrawalType]! + } + + emit PositionClosed( + pid: pid, + poolUUID: self.uuid, + repaymentsByType: repaymentsEvent, + withdrawalsByType: withdrawalsEvent + ) + } + + /// 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 + /// via the DeFiActions.Source abstraction. + /// + /// Queued Deposits: + /// - Any unprocessed queued deposits are extracted and merged into the return array (dedup by type) + /// + /// Steps: + /// 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 — one per token type — containing collateral + queued deposits + 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: Get all debts from position + let debtsByType = self._getPositionDebts(pid: pid) + + // Step 3: Repay all debts by pulling from sources + self._repayDebtsFromSources(pid: pid, debtsByType: debtsByType, sources: repaymentSources) + + // Step 4: Verify ALL debt is EXACTLY repaid (zero tolerance) + self._verifyNoDebtRemains(pid: pid) + + // 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 + let vaults <- self._withdrawAllCollateral(pid: pid, collateralTypes: 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 vaultType in vaults.keys { + let ref = (&vaults[vaultType] as &{FungibleToken.Vault}?)! + withdrawalsByType[vaultType] = ref.balance + } + + // Step 9: Emit position closed event + self._emitPositionClosedEvent(pid: pid, debtsByType: debtsByType, withdrawalsByType: withdrawalsByType) + + // 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: Remove stale queue entry, then destroy InternalPosition and unlock + self._removePositionFromUpdateQueue(pid: pid) + destroy self.positions.remove(key: pid)! + self._unlockPosition(pid) + + return <- returnVaults + } + /////////////////////// // POOL MANAGEMENT /////////////////////// @@ -3387,6 +3706,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))") @@ -3506,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 @@ -3623,6 +3952,21 @@ 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) { + // 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 + } + } + /// 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 { @@ -3852,6 +4196,34 @@ access(all) contract FlowALPv0 { return pool.getPositionDetails(pid: self.id).balances } + /// Returns the total debt for this position, grouped by token type. + /// This is a convenience method for strategies to avoid recalculating debt from balances. + /// + /// Supports multiple debt token types - returns a dictionary mapping each debt token type + /// to its outstanding amount. + /// + /// @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} = {} + + // Collect debts by token type + for balance in balances { + if balance.direction == BalanceDirection.Debit { + let tokenType = balance.vaultType + // 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 + } + } + + return debtsByType + } + /// 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 +4337,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 repaymentSources: Array of sources (one per debt type) from which debt repayments can be withdrawn + /// @return Array of vaults containing all collateral + queued deposits + any overpayment dust, one per token type + /// + access(FungibleToken.Withdraw) fun closePosition( + repaymentSources: [{DeFiActions.Source}] + ): @[{FungibleToken.Vault}] { + let pool = self.pool.borrow()! + return <- pool.closePosition( + pid: self.id, + repaymentSources: repaymentSources + ) + } + /// 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. /// diff --git a/cadence/tests/close_position_precision_test.cdc b/cadence/tests/close_position_precision_test.cdc new file mode 100644 index 00000000..4a78626f --- /dev/null +++ b/cadence/tests/close_position_precision_test.cdc @@ -0,0 +1,286 @@ +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) + + // 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) + + // 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", + [UInt64(1)], + user + ) + 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") +} + +// ============================================================================= +// Test 3: 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(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(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(2), true], PROTOCOL_ACCOUNT) + Test.expect(reb3, Test.beSucceeded()) + + // Get final position state + let finalDetails = getPositionDetails(pid: 2, beFailed: false) + log("\n--- Final State ---") + log("Health: \(finalDetails.health)") + logBalances(finalDetails.balances) + + // Close position - may have tiny shortfall due to accumulated rounding + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(2)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + log("✅ Successfully closed after 3 rebalances (precision shortfall automatically handled)") +} + +// ============================================================================= +// Test 4: 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(3), true], + PROTOCOL_ACCOUNT + ) + Test.expect(rebalanceRes, Test.beSucceeded()) + + let details = getPositionDetails(pid: 3, beFailed: false) + log("Health: \(details.health)") + volCount = volCount + 1 + } + + log("\n--- Closing after extreme volatility ---") + + // Close position + let closeRes = _executeTransaction( + "../transactions/flow-alp/position/repay_and_close_position.cdc", + [UInt64(3)], + user + ) + Test.expect(closeRes, Test.beSucceeded()) + + log("✅ Successfully closed after extreme volatility (balance increased/fell dramatically)") +} + +// ============================================================================= +// Test 5: Close position with insufficient debt repayment +// ============================================================================= +access(all) +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(4)], + 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_with_queued_deposits_test.cdc b/cadence/tests/close_position_with_queued_deposits_test.cdc new file mode 100644 index 00000000..09bc84d4 --- /dev/null +++ b/cadence/tests/close_position_with_queued_deposits_test.cdc @@ -0,0 +1,182 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowToken" +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 +// +// Tests that position closure correctly handles: +// 1. Queued deposits that were not yet processed +// 2. Overpayment during debt repayment that becomes collateral +// ----------------------------------------------------------------------------- + +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 + equalWithinVariance(flowBalanceAfterDeposit, expectedAfterDeposit) + + // 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 + equalWithinVariance(flowBalanceAfter, expectedFinal) + + 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/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) } - 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/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() + } +} 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..1ebff4e7 --- /dev/null +++ b/cadence/tests/transactions/position/deposit_to_position_by_id.cdc @@ -0,0 +1,30 @@ +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 + + 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 + } + + execute { + // Deposit to the position + self.position.depositAndPush(from: <-self.collateral, pushToDrawDownSink: pushToDrawDownSink) + } +} 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..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,64 +1,89 @@ -// Repay MOET debt and withdraw collateral from a position +// This transaction closes a position, if that position only holds MOET-typed debt balances. // -// 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 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 +// 4. Removes/destroys the closed Position resource from PositionManager // -// 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) +// 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" transaction(positionId: UInt64) { + let manager: auth(FungibleToken.Withdraw, FlowALPv0.EPositionAdmin) &FlowALPv0.PositionManager let position: auth(FungibleToken.Withdraw) &FlowALPv0.Position - let receiverRef: &{FungibleToken.Receiver} - let moetWithdrawRef: auth(FungibleToken.Withdraw) &{FungibleToken.Vault} + let flowReceiverRef: &{FungibleToken.Receiver} + let moetReceiverRef: &{FungibleToken.Receiver} + 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( + 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 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") - // 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") + self.moetReceiverRef = borrower.capabilities.borrow<&{FungibleToken.Receiver}>( + MOET.VaultPublicPath + ) ?? panic("Could not borrow MOET receiver reference") + + // 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 { - // 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) - } - - // 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 + // 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 ) - // Deposit withdrawn collateral to user's vault - self.receiverRef.deposit(from: <-withdrawnVault) + // 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 { + 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 + + // 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 } -} +} 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)) + } +} 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 +}