From a034c2d5fc256118e2a8b8cc0ff610c431e835a3 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Wed, 4 Mar 2026 16:56:18 -0400 Subject: [PATCH 1/2] Fix MOET over-repayment routing in deposit path --- cadence/contracts/FlowALPv0.cdc | 34 ++++++-- cadence/tests/moet_repayment_split_test.cdc | 91 +++++++++++++++++++++ 2 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 cadence/tests/moet_repayment_split_test.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 9e11397e..49f3b0d2 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1333,9 +1333,12 @@ access(all) contract FlowALPv0 { } let positionBalance = position.getBalance(type) - // Determine if this is a repayment or collateral deposit - // based on the current balance state - let isRepayment = positionBalance != nil && positionBalance!.direction == FlowALPModels.BalanceDirection.Debit + // Determine if this deposit starts as a repayment based on pre-deposit state. + // If acceptedAmount exceeds the current debt, we split the deposit into: + // - repayment portion (routed via depositRepayment) + // - surplus collateral portion (routed via depositCollateral) + let isRepayment = positionBalance != nil + && positionBalance!.direction == FlowALPModels.BalanceDirection.Debit // If this position doesn't currently have an entry for this token, create one. if positionBalance == nil { @@ -1355,6 +1358,18 @@ access(all) contract FlowALPv0 { // will be recorded at that time. let acceptedAmount = from.balance + var repaymentAmount: UFix64 = 0.0 + if isRepayment { + let debtBalanceBefore = FlowALPMath.scaledBalanceToTrueBalance( + positionBalance!.scaledBalance, + interestIndex: tokenState.getDebitInterestIndex() + ) + if debtBalanceBefore >= UFix128(acceptedAmount) { + repaymentAmount = acceptedAmount + } else { + repaymentAmount = UFix64(debtBalanceBefore) + } + } position.borrowBalance(type)!.recordDeposit( amount: UFix128(acceptedAmount), tokenState: tokenState @@ -1364,13 +1379,18 @@ access(all) contract FlowALPv0 { // Only the accepted amount consumes capacity; queued portions will consume capacity when processed later tokenState.consumeDepositCapacity(acceptedAmount, pid: pid) - // Use reserve handler to deposit (burns MOET repayments, deposits to reserves for collateral/other tokens) + // Route repayment vs collateral portions through the reserve handler. + // For MOET, repayment is burned and surplus is deposited as collateral. let depositReserveOps = self.state.getTokenState(type)!.getReserveOperations() let depositStateRef = &self.state as auth(FlowALPModels.EImplementation) &{FlowALPModels.PoolState} - if isRepayment { - depositReserveOps.depositRepayment(state: depositStateRef, from: <-from) - } else { + if repaymentAmount > 0.0 { + let repaymentVault <- from.withdraw(amount: repaymentAmount) + depositReserveOps.depositRepayment(state: depositStateRef, from: <-repaymentVault) + } + if from.balance > 0.0 { depositReserveOps.depositCollateral(state: depositStateRef, from: <-from) + } else { + Burner.burn(<-from) } self._queuePositionForUpdateIfNecessary(pid: pid) diff --git a/cadence/tests/moet_repayment_split_test.cdc b/cadence/tests/moet_repayment_split_test.cdc new file mode 100644 index 00000000..78f37b52 --- /dev/null +++ b/cadence/tests/moet_repayment_split_test.cdc @@ -0,0 +1,91 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowALPv0" +import "FlowALPModels" +import "test_helpers.cdc" + +access(all) +fun setup() { + deployContracts() +} + +/// Regression test for MOET over-repayment routing: +/// when deposit amount > debt, only the debt portion should be treated as repayment, +/// and the surplus must be routed as collateral into reserves. +access(all) +fun testMoetOverRepaymentSplitsRepayAndCollateral() { + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + 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 FLOW collateral and auto-borrow MOET. + let openRes = executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [1_000.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + let pid: UInt64 = 0 + let debtAmount = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + Test.assert(debtAmount > 0.0, message: "Expected non-zero MOET debt to be borrowed") + + let surplus: UFix64 = 50.0 + mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: surplus, beFailed: false) + + let reserveBefore = getReserveBalance(vaultIdentifier: MOET_TOKEN_IDENTIFIER) + + // Over-repay by exactly `surplus`. + depositToPosition( + signer: user, + positionID: pid, + amount: debtAmount + surplus, + vaultStoragePath: MOET.VaultStoragePath, + pushToDrawDownSink: false + ) + + let reserveAfter = getReserveBalance(vaultIdentifier: MOET_TOKEN_IDENTIFIER) + let reserveDelta = reserveAfter - reserveBefore + + Test.assert( + reserveDelta >= surplus - 0.01 && reserveDelta <= surplus + 0.01, + message: "Expected MOET reserve delta ~".concat(surplus.toString()).concat(", got ").concat(reserveDelta.toString()) + ) + + let moetBalance = getPositionBalance(pid: pid, vaultID: MOET_TOKEN_IDENTIFIER) + Test.assertEqual(FlowALPModels.BalanceDirection.Credit, moetBalance.direction) + Test.assert( + moetBalance.balance >= surplus - 0.01 && moetBalance.balance <= surplus + 0.01, + message: "Expected MOET position credit ~".concat(surplus.toString()).concat(", got ").concat(moetBalance.balance.toString()) + ) + + // Surplus should be withdrawable because it is reserve-backed collateral. + withdrawFromPosition( + signer: user, + positionId: pid, + tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, + amount: surplus, + pullFromTopUpSource: false + ) + + let userMoetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + Test.assert( + userMoetAfter >= surplus - 0.01 && userMoetAfter <= surplus + 0.01, + message: "Expected user MOET balance ~".concat(surplus.toString()).concat(", got ").concat(userMoetAfter.toString()) + ) +} From 1bb7492cfde44abb3612ae82e91d6bbd38ac71e5 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Wed, 4 Mar 2026 17:38:55 -0400 Subject: [PATCH 2/2] Expand comments in MOET over-repayment regression test --- cadence/tests/moet_repayment_split_test.cdc | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/cadence/tests/moet_repayment_split_test.cdc b/cadence/tests/moet_repayment_split_test.cdc index 78f37b52..a812faf9 100644 --- a/cadence/tests/moet_repayment_split_test.cdc +++ b/cadence/tests/moet_repayment_split_test.cdc @@ -16,8 +16,11 @@ fun setup() { /// and the surplus must be routed as collateral into reserves. access(all) fun testMoetOverRepaymentSplitsRepayAndCollateral() { + // Keep prices simple (1:1) so debt and token deltas are easy to reason about. setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + // Create pool with MOET as default debt token and FLOW as supported collateral. + // Large deposit limits/caps remove capacity effects from this scenario. createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) addSupportedTokenZeroRateCurve( signer: PROTOCOL_ACCOUNT, @@ -33,7 +36,7 @@ fun testMoetOverRepaymentSplitsRepayAndCollateral() { mintFlow(to: user, amount: 1_000.0) grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - // Open position with FLOW collateral and auto-borrow MOET. + // Open position with FLOW collateral and auto-borrow MOET so the position starts with MOET debt. let openRes = executeTransaction( "../transactions/flow-alp/position/create_position.cdc", [1_000.0, FLOW_VAULT_STORAGE_PATH, true], @@ -45,9 +48,15 @@ fun testMoetOverRepaymentSplitsRepayAndCollateral() { let debtAmount = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! Test.assert(debtAmount > 0.0, message: "Expected non-zero MOET debt to be borrowed") + // Mint extra MOET so the user can over-repay on purpose. + // We use an exact surplus amount so reserve/accounting deltas are deterministic. let surplus: UFix64 = 50.0 mintMoet(signer: PROTOCOL_ACCOUNT, to: user.address, amount: surplus, beFailed: false) + // Snapshot MOET reserves before over-repayment. + // Expected fixed behavior: + // - debtAmount is repayment (burned for MOET) + // - surplus is collateral (must be deposited to reserves) let reserveBefore = getReserveBalance(vaultIdentifier: MOET_TOKEN_IDENTIFIER) // Over-repay by exactly `surplus`. @@ -59,6 +68,8 @@ fun testMoetOverRepaymentSplitsRepayAndCollateral() { pushToDrawDownSink: false ) + // The reserve should increase by ~surplus only. + // This is the core regression check: pre-fix code burned full amount and reserve delta was ~0. let reserveAfter = getReserveBalance(vaultIdentifier: MOET_TOKEN_IDENTIFIER) let reserveDelta = reserveAfter - reserveBefore @@ -67,6 +78,8 @@ fun testMoetOverRepaymentSplitsRepayAndCollateral() { message: "Expected MOET reserve delta ~".concat(surplus.toString()).concat(", got ").concat(reserveDelta.toString()) ) + // Position should end with MOET credit equal to surplus: + // debt was fully repaid, excess became collateral credit. let moetBalance = getPositionBalance(pid: pid, vaultID: MOET_TOKEN_IDENTIFIER) Test.assertEqual(FlowALPModels.BalanceDirection.Credit, moetBalance.direction) Test.assert( @@ -75,6 +88,7 @@ fun testMoetOverRepaymentSplitsRepayAndCollateral() { ) // Surplus should be withdrawable because it is reserve-backed collateral. + // If accounting/reserve routing diverged, this withdrawal would fail or under-deliver. withdrawFromPosition( signer: user, positionId: pid,