From 74b736f3b9f34fda8d032ecc802ea59e124b728d Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Thu, 19 Mar 2026 19:23:05 -0700 Subject: [PATCH 1/8] fix: withdrawAndPull rebalances to targetHealth when pullFromTopUpSource is true Previously, withdrawAndPull only triggered the top-up source when health dropped below minHealth, while depositAndPush always rebalanced to targetHealth. This asymmetry left positions between minHealth and targetHealth without rebalancing. Now both operations consistently rebalance to targetHealth when their respective flags are set. Co-Authored-By: Claude Opus 4.6 (1M context) --- cadence/contracts/FlowALPv0.cdc | 44 +++++---- .../withdraw_and_pull_rebalance_test.cdc | 89 +++++++++++++++++++ 2 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 cadence/tests/withdraw_and_pull_rebalance_test.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 224bf8e1..28206962 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1237,8 +1237,8 @@ access(all) contract FlowALPv0 { /// Withdraws the requested funds from the specified position /// with the configurable `pullFromTopUpSource` option. /// - /// If `pullFromTopUpSource` is true, deficient value putting the position below its min health - /// is pulled from the position's configured `topUpSource`. + /// If `pullFromTopUpSource` is true, any deficiency below the position's target health + /// is pulled from the position's configured `topUpSource` (consistent with depositAndPush). /// TODO(jord): ~150-line function - consider refactoring. access(FlowALPModels.EPosition) fun withdrawAndPull( pid: UInt64, @@ -1275,7 +1275,8 @@ access(all) contract FlowALPv0 { let topUpSource = position.borrowTopUpSource() let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() - let requiredDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( + // Compute the deposit required to maintain minHealth — the hard requirement. + let minHealthDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( pid: pid, depositType: topUpType, targetHealth: position.getMinHealth(), @@ -1283,32 +1284,37 @@ access(all) contract FlowALPv0 { withdrawAmount: amount ) + // When pullFromTopUpSource is true, also check whether a deposit is needed + // to maintain targetHealth (consistent with depositAndPush behaviour). + let targetHealthDeposit = pullFromTopUpSource + ? self.fundsRequiredForTargetHealthAfterWithdrawing( + pid: pid, + depositType: topUpType, + targetHealth: position.getTargetHealth(), + withdrawType: type, + withdrawAmount: amount + ) + : 0.0 + var canWithdraw = false - if requiredDeposit == 0.0 { - // We can service this withdrawal without any top up + if minHealthDeposit == 0.0 && targetHealthDeposit == 0.0 { + // No top-up needed: position stays above targetHealth (or minHealth when not pulling) canWithdraw = true } else if pullFromTopUpSource { // We need more funds to service this withdrawal, see if they are available from the top up source if let topUpSource = topUpSource { - // If we have to rebalance, let's try to rebalance to the target health, not just the minimum - let idealDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( - pid: pid, - depositType: topUpType, - targetHealth: position.getTargetHealth(), - withdrawType: type, - withdrawAmount: amount - ) + // Try to rebalance to target health + let idealDeposit = targetHealthDeposit > 0.0 ? targetHealthDeposit : minHealthDeposit let pulledVault <- topUpSource.withdrawAvailable(maxAmount: idealDeposit) assert(pulledVault.getType() == topUpType, message: "topUpSource returned unexpected token type") let pulledAmount = pulledVault.balance - - // NOTE: We requested the "ideal" deposit, but we compare against the required deposit here. - // The top up source may not have enough funds get us to the target health, but could have - // enough to keep us over the minimum. - if pulledAmount >= requiredDeposit { + // NOTE: We requested the "ideal" deposit (targetHealth), but the hard requirement + // is minHealth. The top up source may not have enough to reach targetHealth, + // but the withdrawal can proceed as long as we stay above minHealth. + if pulledAmount >= minHealthDeposit { // We can service this withdrawal if we deposit funds from our top up source self._depositEffectsOnly( pid: pid, @@ -1334,7 +1340,7 @@ access(all) contract FlowALPv0 { log(" [CONTRACT] Token type: \(type.identifier)") log(" [CONTRACT] Requested amount: \(amount)") log(" [CONTRACT] Available balance (without topUp): \(availableBalance)") - log(" [CONTRACT] Required deposit for minHealth: \(requiredDeposit)") + log(" [CONTRACT] Required deposit for minHealth: \(minHealthDeposit)") log(" [CONTRACT] Pull from topUpSource: \(pullFromTopUpSource)") } // We can't service this withdrawal, so we just abort diff --git a/cadence/tests/withdraw_and_pull_rebalance_test.cdc b/cadence/tests/withdraw_and_pull_rebalance_test.cdc new file mode 100644 index 00000000..3909fdf3 --- /dev/null +++ b/cadence/tests/withdraw_and_pull_rebalance_test.cdc @@ -0,0 +1,89 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "test_helpers.cdc" + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun setup() { + deployContracts() + + snapshot = getCurrentBlockHeight() +} + +/// Verifies that withdrawAndPull with pullFromTopUpSource=true rebalances +/// the position back to targetHealth, not just minHealth. +/// +/// Setup: +/// - User deposits 1000 FLOW (price=1.0, CF=0.8) with auto-borrow. +/// - Position starts at targetHealth=1.3 with ~615.38 MOET debt. +/// - User's MOET vault (topUpSource) holds ~615.38 MOET. +/// +/// Action: +/// - Withdraw a small FLOW amount that drops health below targetHealth +/// but keeps it above minHealth (1.1). +/// - Use pullFromTopUpSource=true. +/// +/// Expected (consistent with depositAndPush): +/// - The protocol should pull from the topUpSource to rebalance +/// back to targetHealth, not just leave the position between +/// minHealth and targetHealth. +access(all) +fun test_withdrawAndPull_rebalancesToTargetHealth() { + let initialPrice = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice) + + 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 auto-borrow: deposits 1000 FLOW, borrows ~615.38 MOET. + // Health starts at targetHealth (1.3). + 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 healthBefore = getPositionHealth(pid: 0, beFailed: false) + let tolerance: UFix128 = 0.01 + Test.assert( + healthBefore >= INT_TARGET_HEALTH - tolerance && healthBefore <= INT_TARGET_HEALTH + tolerance, + message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString()) + ) + + // Withdraw 50 FLOW with pullFromTopUpSource=true. + // Without the fix: health drops below targetHealth but stays above minHealth, + // so pullFromTopUpSource is ignored and the position is NOT rebalanced. + // With the fix: the protocol should pull from topUpSource to restore targetHealth. + withdrawFromPosition( + signer: user, + positionId: 0, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + amount: 50.0, + pullFromTopUpSource: true + ) + + let healthAfter = getPositionHealth(pid: 0, beFailed: false) + + // The position health should be restored to targetHealth (1.3), + // NOT left between minHealth and targetHealth. + Test.assert( + healthAfter >= INT_TARGET_HEALTH - tolerance, + message: "With pullFromTopUpSource=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString()) + ) +} From 522cd8339878a69e5005072582d02190fd8d3629 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 20 Mar 2026 08:27:53 -0700 Subject: [PATCH 2/8] test: move withdrawAndPull test and add depositAndPush counterpart Move test_withdrawAndPull_rebalancesToTargetHealth into rebalance_undercollateralised_test.cdc and add the symmetric testDepositAndPush_rebalancesToTargetHealth to rebalance_overcollateralised_test.cdc. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../rebalance_overcollateralised_test.cdc | 66 +++++++++++++- .../rebalance_undercollateralised_test.cdc | 63 +++++++++++++ .../withdraw_and_pull_rebalance_test.cdc | 89 ------------------- 3 files changed, 128 insertions(+), 90 deletions(-) delete mode 100644 cadence/tests/withdraw_and_pull_rebalance_test.cdc diff --git a/cadence/tests/rebalance_overcollateralised_test.cdc b/cadence/tests/rebalance_overcollateralised_test.cdc index 9e7d9ca6..6fb3d91a 100644 --- a/cadence/tests/rebalance_overcollateralised_test.cdc +++ b/cadence/tests/rebalance_overcollateralised_test.cdc @@ -93,4 +93,68 @@ fun testRebalanceOvercollateralised() { let userMoetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! Test.assert(userMoetBalance >= expectedDebt - tolerance && userMoetBalance <= expectedDebt + tolerance, message: "User MOET balance should reflect new debt (~".concat(expectedDebt.toString()).concat(") but was ").concat(userMoetBalance.toString())) -} +} + +/// Verifies that depositAndPush with pushToDrawDownSink=true rebalances +/// the position back to targetHealth by pushing excess value to the sink. +/// This is the overcollateralised counterpart to +/// testWithdrawAndPull_rebalancesToTargetHealth. +access(all) +fun testDepositAndPush_rebalancesToTargetHealth() { + Test.reset(to: snapshot) + + let initialPrice = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOET_TOKEN_IDENTIFIER, price: initialPrice) + + 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_100.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Open position with auto-borrow: deposits 1000 FLOW, borrows ~615.38 MOET. + // Health starts at targetHealth (1.3). + 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 healthBefore = getPositionHealth(pid: 0, beFailed: false) + let tolerance128: UFix128 = 0.01 + Test.assert( + healthBefore >= INT_TARGET_HEALTH - tolerance128 && healthBefore <= INT_TARGET_HEALTH + tolerance128, + message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString()) + ) + + // Deposit 100 more FLOW with pushToDrawDownSink=true. + // This pushes health above targetHealth, so the protocol should rebalance + // by pushing excess value to the drawdown sink, restoring health to targetHealth. + depositToPosition( + signer: user, + positionID: 0, + amount: 100.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: true + ) + + let healthAfter = getPositionHealth(pid: 0, beFailed: false) + + // The position health should be restored to targetHealth (1.3), + // NOT left above targetHealth. + Test.assert( + healthAfter >= INT_TARGET_HEALTH - tolerance128 && healthAfter <= INT_TARGET_HEALTH + tolerance128, + message: "With pushToDrawDownSink=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString()) + ) +} diff --git a/cadence/tests/rebalance_undercollateralised_test.cdc b/cadence/tests/rebalance_undercollateralised_test.cdc index 960b1cef..e9fa40aa 100644 --- a/cadence/tests/rebalance_undercollateralised_test.cdc +++ b/cadence/tests/rebalance_undercollateralised_test.cdc @@ -174,3 +174,66 @@ fun testRebalanceUndercollateralised_InsufficientTopUpSource() { Test.expect(rebalanceRes, Test.beFailed()) Test.assertError(rebalanceRes, errorMessage: "topUpSource insufficient to save position from liquidation") } + +/// Verifies that withdrawAndPull with pullFromTopUpSource=true rebalances +/// the position back to targetHealth, not just minHealth. +/// This ensures symmetry with depositAndPush(pushToDrawDownSink=true). +access(all) +fun testWithdrawAndPull_rebalancesToTargetHealth() { + Test.reset(to: snapshot) + + let initialPrice = 1.0 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice) + + 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 auto-borrow: deposits 1000 FLOW, borrows ~615.38 MOET. + // Health starts at targetHealth (1.3). + 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 healthBefore = getPositionHealth(pid: 0, beFailed: false) + let tolerance: UFix128 = 0.01 + Test.assert( + healthBefore >= INT_TARGET_HEALTH - tolerance && healthBefore <= INT_TARGET_HEALTH + tolerance, + message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString()) + ) + + // Withdraw 50 FLOW with pullFromTopUpSource=true. + // Without the fix: health drops below targetHealth but stays above minHealth, + // so pullFromTopUpSource is ignored and the position is NOT rebalanced. + // With the fix: the protocol pulls from topUpSource to restore targetHealth. + withdrawFromPosition( + signer: user, + positionId: 0, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + amount: 50.0, + pullFromTopUpSource: true + ) + + let healthAfter = getPositionHealth(pid: 0, beFailed: false) + + // The position health should be restored to targetHealth (1.3), + // NOT left between minHealth and targetHealth. + Test.assert( + healthAfter >= INT_TARGET_HEALTH - tolerance, + message: "With pullFromTopUpSource=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString()) + ) +} diff --git a/cadence/tests/withdraw_and_pull_rebalance_test.cdc b/cadence/tests/withdraw_and_pull_rebalance_test.cdc deleted file mode 100644 index 3909fdf3..00000000 --- a/cadence/tests/withdraw_and_pull_rebalance_test.cdc +++ /dev/null @@ -1,89 +0,0 @@ -import Test -import BlockchainHelpers - -import "MOET" -import "test_helpers.cdc" - -access(all) var snapshot: UInt64 = 0 - -access(all) -fun setup() { - deployContracts() - - snapshot = getCurrentBlockHeight() -} - -/// Verifies that withdrawAndPull with pullFromTopUpSource=true rebalances -/// the position back to targetHealth, not just minHealth. -/// -/// Setup: -/// - User deposits 1000 FLOW (price=1.0, CF=0.8) with auto-borrow. -/// - Position starts at targetHealth=1.3 with ~615.38 MOET debt. -/// - User's MOET vault (topUpSource) holds ~615.38 MOET. -/// -/// Action: -/// - Withdraw a small FLOW amount that drops health below targetHealth -/// but keeps it above minHealth (1.1). -/// - Use pullFromTopUpSource=true. -/// -/// Expected (consistent with depositAndPush): -/// - The protocol should pull from the topUpSource to rebalance -/// back to targetHealth, not just leave the position between -/// minHealth and targetHealth. -access(all) -fun test_withdrawAndPull_rebalancesToTargetHealth() { - let initialPrice = 1.0 - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice) - - 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 auto-borrow: deposits 1000 FLOW, borrows ~615.38 MOET. - // Health starts at targetHealth (1.3). - 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 healthBefore = getPositionHealth(pid: 0, beFailed: false) - let tolerance: UFix128 = 0.01 - Test.assert( - healthBefore >= INT_TARGET_HEALTH - tolerance && healthBefore <= INT_TARGET_HEALTH + tolerance, - message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString()) - ) - - // Withdraw 50 FLOW with pullFromTopUpSource=true. - // Without the fix: health drops below targetHealth but stays above minHealth, - // so pullFromTopUpSource is ignored and the position is NOT rebalanced. - // With the fix: the protocol should pull from topUpSource to restore targetHealth. - withdrawFromPosition( - signer: user, - positionId: 0, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - amount: 50.0, - pullFromTopUpSource: true - ) - - let healthAfter = getPositionHealth(pid: 0, beFailed: false) - - // The position health should be restored to targetHealth (1.3), - // NOT left between minHealth and targetHealth. - Test.assert( - healthAfter >= INT_TARGET_HEALTH - tolerance, - message: "With pullFromTopUpSource=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString()) - ) -} From df835e4af4194231428794431574fe64a532f5ae Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 20 Mar 2026 08:34:38 -0700 Subject: [PATCH 3/8] docs: clarify push/pull always rebalance to targetHealth, use equalWithinVariance Update docstrings for withdrawAndPull and depositAndPush across FlowALPv0, FlowALPPositionResources, and FlowALPModels to state that the push/pull always occurs at withdraw/deposit time and always attempts to restore targetHealth. Switch tests to equalWithinVariance for rounding-safe comparisons. Co-Authored-By: Claude Opus 4.6 (1M context) --- cadence/contracts/FlowALPModels.cdc | 4 +-- .../contracts/FlowALPPositionResources.cdc | 8 +++--- cadence/contracts/FlowALPv0.cdc | 8 +++--- .../rebalance_overcollateralised_test.cdc | 26 ++++++------------- .../rebalance_undercollateralised_test.cdc | 26 ++++++------------- 5 files changed, 26 insertions(+), 46 deletions(-) diff --git a/cadence/contracts/FlowALPModels.cdc b/cadence/contracts/FlowALPModels.cdc index 42f60eb5..745c437f 100644 --- a/cadence/contracts/FlowALPModels.cdc +++ b/cadence/contracts/FlowALPModels.cdc @@ -2108,14 +2108,14 @@ access(all) contract FlowALPModels { /// Borrows an authorized internal position reference. access(EPosition) view fun borrowPosition(pid: UInt64): auth(EImplementation) &{InternalPosition} - /// Deposits funds to a position and optionally pushes excess to draw-down sink. + /// Deposits funds to a position. If `pushToDrawDownSink` is true, always rebalances to targetHealth. access(EPosition) fun depositAndPush( pid: UInt64, from: @{FungibleToken.Vault}, pushToDrawDownSink: Bool ) - /// Withdraws funds from a position and optionally pulls deficit from top-up source. + /// Withdraws funds from a position. If `pullFromTopUpSource` is true, always rebalances to targetHealth. access(EPosition) fun withdrawAndPull( pid: UInt64, type: Type, diff --git a/cadence/contracts/FlowALPPositionResources.cdc b/cadence/contracts/FlowALPPositionResources.cdc index ba406725..8362352c 100644 --- a/cadence/contracts/FlowALPPositionResources.cdc +++ b/cadence/contracts/FlowALPPositionResources.cdc @@ -120,8 +120,8 @@ access(all) contract FlowALPPositionResources { ) } - /// Deposits funds to the Position enabling the caller to configure whether excess value - /// should be pushed to the drawDownSink if the deposit puts the Position above its maximum health + /// Deposits funds to the Position. If `pushToDrawDownSink` is true, a rebalance is always + /// triggered at deposit time, pushing excess value to the drawDownSink to restore targetHealth. /// NOTE: Anyone is allowed to deposit to any position. access(all) fun depositAndPush( from: @{FungibleToken.Vault}, @@ -145,8 +145,8 @@ access(all) contract FlowALPPositionResources { ) } - /// Withdraws funds from the Position enabling the caller to configure whether insufficient value - /// should be pulled from the topUpSource if the withdrawal puts the Position below its minimum health + /// Withdraws funds from the Position. If `pullFromTopUpSource` is true, a rebalance is always + /// triggered at withdrawal time, pulling value from the topUpSource to restore targetHealth. access(FungibleToken.Withdraw) fun withdrawAndPull( type: Type, amount: UFix64, diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 28206962..e38d8cdc 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1183,8 +1183,8 @@ access(all) contract FlowALPv0 { } /// Deposits the provided funds to the specified position with the configurable `pushToDrawDownSink` option. - /// If `pushToDrawDownSink` is true, excess value putting the position above its max health - /// is pushed to the position's configured `drawDownSink`. + /// If `pushToDrawDownSink` is true, a rebalance is always triggered at deposit time, + /// pushing excess value to the position's configured `drawDownSink` to restore targetHealth. access(FlowALPModels.EPosition) fun depositAndPush( pid: UInt64, from: @{FungibleToken.Vault}, @@ -1237,8 +1237,8 @@ access(all) contract FlowALPv0 { /// Withdraws the requested funds from the specified position /// with the configurable `pullFromTopUpSource` option. /// - /// If `pullFromTopUpSource` is true, any deficiency below the position's target health - /// is pulled from the position's configured `topUpSource` (consistent with depositAndPush). + /// If `pullFromTopUpSource` is true, a rebalance is always triggered at withdrawal time, + /// pulling value from the position's configured `topUpSource` to restore targetHealth. /// TODO(jord): ~150-line function - consider refactoring. access(FlowALPModels.EPosition) fun withdrawAndPull( pid: UInt64, diff --git a/cadence/tests/rebalance_overcollateralised_test.cdc b/cadence/tests/rebalance_overcollateralised_test.cdc index 6fb3d91a..545734af 100644 --- a/cadence/tests/rebalance_overcollateralised_test.cdc +++ b/cadence/tests/rebalance_overcollateralised_test.cdc @@ -95,10 +95,9 @@ fun testRebalanceOvercollateralised() { message: "User MOET balance should reflect new debt (~".concat(expectedDebt.toString()).concat(") but was ").concat(userMoetBalance.toString())) } -/// Verifies that depositAndPush with pushToDrawDownSink=true rebalances -/// the position back to targetHealth by pushing excess value to the sink. -/// This is the overcollateralised counterpart to -/// testWithdrawAndPull_rebalancesToTargetHealth. +/// Verifies that depositAndPush with pushToDrawDownSink=true always +/// rebalances the position back to targetHealth at deposit time by +/// pushing excess value to the sink. access(all) fun testDepositAndPush_rebalancesToTargetHealth() { Test.reset(to: snapshot) @@ -132,15 +131,11 @@ fun testDepositAndPush_rebalancesToTargetHealth() { Test.expect(openRes, Test.beSucceeded()) let healthBefore = getPositionHealth(pid: 0, beFailed: false) - let tolerance128: UFix128 = 0.01 - Test.assert( - healthBefore >= INT_TARGET_HEALTH - tolerance128 && healthBefore <= INT_TARGET_HEALTH + tolerance128, - message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString()) - ) + Test.assert(equalWithinVariance(INT_TARGET_HEALTH, healthBefore), + message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString())) // Deposit 100 more FLOW with pushToDrawDownSink=true. - // This pushes health above targetHealth, so the protocol should rebalance - // by pushing excess value to the drawdown sink, restoring health to targetHealth. + // The push always triggers at deposit time, restoring targetHealth. depositToPosition( signer: user, positionID: 0, @@ -150,11 +145,6 @@ fun testDepositAndPush_rebalancesToTargetHealth() { ) let healthAfter = getPositionHealth(pid: 0, beFailed: false) - - // The position health should be restored to targetHealth (1.3), - // NOT left above targetHealth. - Test.assert( - healthAfter >= INT_TARGET_HEALTH - tolerance128 && healthAfter <= INT_TARGET_HEALTH + tolerance128, - message: "With pushToDrawDownSink=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString()) - ) + Test.assert(equalWithinVariance(INT_TARGET_HEALTH, healthAfter), + message: "With pushToDrawDownSink=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString())) } diff --git a/cadence/tests/rebalance_undercollateralised_test.cdc b/cadence/tests/rebalance_undercollateralised_test.cdc index e9fa40aa..1ca01711 100644 --- a/cadence/tests/rebalance_undercollateralised_test.cdc +++ b/cadence/tests/rebalance_undercollateralised_test.cdc @@ -175,9 +175,9 @@ fun testRebalanceUndercollateralised_InsufficientTopUpSource() { Test.assertError(rebalanceRes, errorMessage: "topUpSource insufficient to save position from liquidation") } -/// Verifies that withdrawAndPull with pullFromTopUpSource=true rebalances -/// the position back to targetHealth, not just minHealth. -/// This ensures symmetry with depositAndPush(pushToDrawDownSink=true). +/// Verifies that withdrawAndPull with pullFromTopUpSource=true always +/// rebalances the position back to targetHealth at withdrawal time, +/// not just when breaching minHealth. access(all) fun testWithdrawAndPull_rebalancesToTargetHealth() { Test.reset(to: snapshot) @@ -210,16 +210,11 @@ fun testWithdrawAndPull_rebalancesToTargetHealth() { Test.expect(openRes, Test.beSucceeded()) let healthBefore = getPositionHealth(pid: 0, beFailed: false) - let tolerance: UFix128 = 0.01 - Test.assert( - healthBefore >= INT_TARGET_HEALTH - tolerance && healthBefore <= INT_TARGET_HEALTH + tolerance, - message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString()) - ) + Test.assert(equalWithinVariance(INT_TARGET_HEALTH, healthBefore), + message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString())) // Withdraw 50 FLOW with pullFromTopUpSource=true. - // Without the fix: health drops below targetHealth but stays above minHealth, - // so pullFromTopUpSource is ignored and the position is NOT rebalanced. - // With the fix: the protocol pulls from topUpSource to restore targetHealth. + // The pull always triggers at withdrawal time, restoring targetHealth. withdrawFromPosition( signer: user, positionId: 0, @@ -229,11 +224,6 @@ fun testWithdrawAndPull_rebalancesToTargetHealth() { ) let healthAfter = getPositionHealth(pid: 0, beFailed: false) - - // The position health should be restored to targetHealth (1.3), - // NOT left between minHealth and targetHealth. - Test.assert( - healthAfter >= INT_TARGET_HEALTH - tolerance, - message: "With pullFromTopUpSource=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString()) - ) + Test.assert(equalWithinVariance(INT_TARGET_HEALTH, healthAfter), + message: "With pullFromTopUpSource=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString())) } From 60dc21cb2033acf80fca5cb4738d78bff621bef0 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 20 Mar 2026 13:02:27 -0700 Subject: [PATCH 4/8] test: comprehensive withdrawAndPull/depositAndPush test suite, fix no-source bug Add 13 scenario tests covering all combinations of pull/push flags, health thresholds, and source/sink availability. Move the two earlier tests into the new file and remove them from the rebalance test files. Fix bug where withdrawAndPull panicked when pullFromTopUpSource=true, health was between minHealth and targetHealth, but no topUpSource was configured. The withdrawal now succeeds since the position is still above minHealth (best-effort semantics). Co-Authored-By: Claude Opus 4.6 (1M context) --- cadence/contracts/FlowALPv0.cdc | 9 +- .../rebalance_overcollateralised_test.cdc | 56 +--- .../rebalance_undercollateralised_test.cdc | 55 +--- .../create_position_no_connectors.cdc | 52 ++++ ...ithdraw_and_pull_deposit_and_push_test.cdc | 263 ++++++++++++++++++ 5 files changed, 323 insertions(+), 112 deletions(-) create mode 100644 cadence/tests/transactions/position-manager/create_position_no_connectors.cdc create mode 100644 cadence/tests/withdraw_and_pull_deposit_and_push_test.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index e38d8cdc..01ace659 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1302,9 +1302,8 @@ access(all) contract FlowALPv0 { // No top-up needed: position stays above targetHealth (or minHealth when not pulling) canWithdraw = true } else if pullFromTopUpSource { - // We need more funds to service this withdrawal, see if they are available from the top up source + // Try to pull from topUpSource to restore targetHealth (best-effort). if let topUpSource = topUpSource { - // Try to rebalance to target health let idealDeposit = targetHealthDeposit > 0.0 ? targetHealthDeposit : minHealthDeposit let pulledVault <- topUpSource.withdrawAvailable(maxAmount: idealDeposit) @@ -1315,7 +1314,6 @@ access(all) contract FlowALPv0 { // is minHealth. The top up source may not have enough to reach targetHealth, // but the withdrawal can proceed as long as we stay above minHealth. if pulledAmount >= minHealthDeposit { - // We can service this withdrawal if we deposit funds from our top up source self._depositEffectsOnly( pid: pid, from: <-pulledVault @@ -1329,6 +1327,11 @@ access(all) contract FlowALPv0 { ) } } + // If no source is configured (or pull was insufficient), the withdrawal + // can still proceed as long as the position stays above minHealth. + if !canWithdraw && minHealthDeposit == 0.0 { + canWithdraw = true + } } if !canWithdraw { diff --git a/cadence/tests/rebalance_overcollateralised_test.cdc b/cadence/tests/rebalance_overcollateralised_test.cdc index 545734af..0c560b36 100644 --- a/cadence/tests/rebalance_overcollateralised_test.cdc +++ b/cadence/tests/rebalance_overcollateralised_test.cdc @@ -93,58 +93,4 @@ fun testRebalanceOvercollateralised() { let userMoetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! Test.assert(userMoetBalance >= expectedDebt - tolerance && userMoetBalance <= expectedDebt + tolerance, message: "User MOET balance should reflect new debt (~".concat(expectedDebt.toString()).concat(") but was ").concat(userMoetBalance.toString())) -} - -/// Verifies that depositAndPush with pushToDrawDownSink=true always -/// rebalances the position back to targetHealth at deposit time by -/// pushing excess value to the sink. -access(all) -fun testDepositAndPush_rebalancesToTargetHealth() { - Test.reset(to: snapshot) - - let initialPrice = 1.0 - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice) - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOET_TOKEN_IDENTIFIER, price: initialPrice) - - 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_100.0) - grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) - - // Open position with auto-borrow: deposits 1000 FLOW, borrows ~615.38 MOET. - // Health starts at targetHealth (1.3). - 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 healthBefore = getPositionHealth(pid: 0, beFailed: false) - Test.assert(equalWithinVariance(INT_TARGET_HEALTH, healthBefore), - message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString())) - - // Deposit 100 more FLOW with pushToDrawDownSink=true. - // The push always triggers at deposit time, restoring targetHealth. - depositToPosition( - signer: user, - positionID: 0, - amount: 100.0, - vaultStoragePath: FLOW_VAULT_STORAGE_PATH, - pushToDrawDownSink: true - ) - - let healthAfter = getPositionHealth(pid: 0, beFailed: false) - Test.assert(equalWithinVariance(INT_TARGET_HEALTH, healthAfter), - message: "With pushToDrawDownSink=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString())) -} +} \ No newline at end of file diff --git a/cadence/tests/rebalance_undercollateralised_test.cdc b/cadence/tests/rebalance_undercollateralised_test.cdc index 1ca01711..6b64dc1a 100644 --- a/cadence/tests/rebalance_undercollateralised_test.cdc +++ b/cadence/tests/rebalance_undercollateralised_test.cdc @@ -173,57 +173,4 @@ fun testRebalanceUndercollateralised_InsufficientTopUpSource() { ) Test.expect(rebalanceRes, Test.beFailed()) Test.assertError(rebalanceRes, errorMessage: "topUpSource insufficient to save position from liquidation") -} - -/// Verifies that withdrawAndPull with pullFromTopUpSource=true always -/// rebalances the position back to targetHealth at withdrawal time, -/// not just when breaching minHealth. -access(all) -fun testWithdrawAndPull_rebalancesToTargetHealth() { - Test.reset(to: snapshot) - - let initialPrice = 1.0 - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice) - - 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 auto-borrow: deposits 1000 FLOW, borrows ~615.38 MOET. - // Health starts at targetHealth (1.3). - 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 healthBefore = getPositionHealth(pid: 0, beFailed: false) - Test.assert(equalWithinVariance(INT_TARGET_HEALTH, healthBefore), - message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString())) - - // Withdraw 50 FLOW with pullFromTopUpSource=true. - // The pull always triggers at withdrawal time, restoring targetHealth. - withdrawFromPosition( - signer: user, - positionId: 0, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - amount: 50.0, - pullFromTopUpSource: true - ) - - let healthAfter = getPositionHealth(pid: 0, beFailed: false) - Test.assert(equalWithinVariance(INT_TARGET_HEALTH, healthAfter), - message: "With pullFromTopUpSource=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString())) -} +} \ No newline at end of file diff --git a/cadence/tests/transactions/position-manager/create_position_no_connectors.cdc b/cadence/tests/transactions/position-manager/create_position_no_connectors.cdc new file mode 100644 index 00000000..dad1227f --- /dev/null +++ b/cadence/tests/transactions/position-manager/create_position_no_connectors.cdc @@ -0,0 +1,52 @@ +import "FungibleToken" + +import "FlowALPv0" +import "FlowALPPositionResources" +import "FlowALPModels" + +/// TEST TRANSACTION - DO NOT USE IN PRODUCTION +/// +/// Creates a position without a topUpSource or drawDownSink. +/// +transaction(amount: UFix64, vaultStoragePath: StoragePath) { + + let collateral: @{FungibleToken.Vault} + let positionManager: auth(FlowALPModels.EPositionAdmin) &FlowALPPositionResources.PositionManager + let poolCap: Capability + let signerAccount: auth(Storage) &Account + + prepare(signer: auth(BorrowValue, Storage, Capabilities) &Account) { + self.signerAccount = signer + + let collateralSource = signer.storage.borrow(from: vaultStoragePath) + ?? panic("Could not borrow reference to Vault from \(vaultStoragePath)") + self.collateral <- collateralSource.withdraw(amount: amount) + + if signer.storage.borrow<&FlowALPPositionResources.PositionManager>(from: FlowALPv0.PositionStoragePath) == nil { + let manager <- FlowALPv0.createPositionManager() + signer.storage.save(<-manager, to: FlowALPv0.PositionStoragePath) + let readCap = signer.capabilities.storage.issue<&FlowALPPositionResources.PositionManager>(FlowALPv0.PositionStoragePath) + signer.capabilities.publish(readCap, at: FlowALPv0.PositionPublicPath) + } + self.positionManager = signer.storage.borrow(from: FlowALPv0.PositionStoragePath) + ?? panic("PositionManager not found") + + self.poolCap = signer.storage.load>( + from: FlowALPv0.PoolCapStoragePath + ) ?? panic("Could not load Pool capability from storage") + } + + execute { + let poolRef = self.poolCap.borrow() ?? panic("Could not borrow Pool capability") + + let position <- poolRef.createPosition( + funds: <-self.collateral, + issuanceSink: nil, + repaymentSource: nil, + pushToDrawDownSink: false + ) + + self.positionManager.addPosition(position: <-position) + self.signerAccount.storage.save(self.poolCap, to: FlowALPv0.PoolCapStoragePath) + } +} diff --git a/cadence/tests/withdraw_and_pull_deposit_and_push_test.cdc b/cadence/tests/withdraw_and_pull_deposit_and_push_test.cdc new file mode 100644 index 00000000..36bb55df --- /dev/null +++ b/cadence/tests/withdraw_and_pull_deposit_and_push_test.cdc @@ -0,0 +1,263 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowALPv0" +import "test_helpers.cdc" + +access(all) let MOET_VAULT_STORAGE_PATH = /storage/moetTokenVault_0x0000000000000007 + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun setup() { + deployContracts() + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOET_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 + ) + snapshot = getCurrentBlockHeight() +} + +/// Creates a user, opens a position with auto-borrow at targetHealth. Position ID is 0. +access(all) +fun setupUserWithPosition(_ flowAmount: UFix64): Test.TestAccount { + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: flowAmount) + createPosition(admin: PROTOCOL_ACCOUNT, signer: user, amount: flowAmount, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) + return user +} + +/// Creates a user, opens a position WITHOUT source/sink. +/// If borrow > 0, an LP position is created first to provide MOET liquidity. +/// The bare position ID is 0 when borrow == 0, or 1 when borrow > 0 (LP is 0). +access(all) +fun setupUserWithBarePosition(_ flowAmount: UFix64, borrow: UFix64): Test.TestAccount { + // If borrowing MOET, we need liquidity in the pool first. + if borrow > 0.0 { + let lp = Test.createAccount() + setupMoetVault(lp, beFailed: false) + mintMoet(signer: PROTOCOL_ACCOUNT, to: lp.address, amount: borrow * 2.0, beFailed: false) + mintFlow(to: lp, amount: 10.0) // small FLOW deposit so LP can create a position + createPosition(admin: PROTOCOL_ACCOUNT, signer: lp, amount: 10.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + depositToPosition(signer: lp, positionID: 0, amount: borrow * 2.0, vaultStoragePath: MOET_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + } + + let user = Test.createAccount() + mintFlow(to: user, amount: flowAmount) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + let res = _executeTransaction( + "./transactions/position-manager/create_position_no_connectors.cdc", + [flowAmount, FLOW_VAULT_STORAGE_PATH], + user + ) + Test.expect(res, Test.beSucceeded()) + + if borrow > 0.0 { + setupMoetVault(user, beFailed: false) + let pid = UInt64(1) // bare position is after LP position + borrowFromPosition(signer: user, positionId: pid, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, vaultStoragePath: MOET_VAULT_STORAGE_PATH, amount: borrow, beFailed: false) + } + return user +} + +// ============================================================ +// withdrawAndPull tests +// ============================================================ + +/// Scenario 1: pull=false, health stays above minHealth → succeed. +access(all) +fun test_withdraw_noPull_aboveMinHealth_succeeds() { + let user = setupUserWithPosition(1_000.0) + + withdrawFromPosition(signer: user, positionId: 0, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, amount: 10.0, pullFromTopUpSource: false) + + Test.assert(getPositionHealth(pid: 0, beFailed: false) > INT_MIN_HEALTH) +} + +/// Scenario 2: pull=false, health breaches minHealth → fail. +access(all) +fun test_withdraw_noPull_breachesMinHealth_fails() { + Test.reset(to: snapshot) + let user = setupUserWithPosition(1_000.0) + + let res = _executeTransaction( + "./transactions/position-manager/withdraw_from_position.cdc", + [0 as UInt64, FLOW_TOKEN_IDENTIFIER, 900.0, false], + user + ) + Test.expect(res, Test.beFailed()) +} + +/// Scenario 3: pull=true, health stays above targetHealth → succeed, no pull. +access(all) +fun test_withdraw_pull_aboveTargetHealth_noPull() { + Test.reset(to: snapshot) + let user = setupUserWithPosition(1_000.0) + // Deposit extra FLOW without push to raise health above targetHealth + mintFlow(to: user, amount: 100.0) + depositToPosition(signer: user, positionID: 0, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + + let moetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + + // Small withdrawal keeps health above targetHealth — no pull should occur + withdrawFromPosition(signer: user, positionId: 0, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, amount: 10.0, pullFromTopUpSource: true) + + let moetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + Test.assert(equalWithinVariance(moetBefore, moetAfter), + message: "No pull should occur. MOET before: ".concat(moetBefore.toString()).concat(", after: ").concat(moetAfter.toString())) +} + +/// Scenario 4: pull=true, health between min and target, source has enough → restores targetHealth. +access(all) +fun test_withdraw_pull_belowTarget_sourceHasEnough_restoresTarget() { + Test.reset(to: snapshot) + let user = setupUserWithPosition(1_000.0) + + withdrawFromPosition(signer: user, positionId: 0, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, amount: 50.0, pullFromTopUpSource: true) + + Test.assert(equalWithinVariance(INT_TARGET_HEALTH, getPositionHealth(pid: 0, beFailed: false))) +} + +/// Scenario 5: pull=true, health between min and target, source has partial funds → best-effort. +access(all) +fun test_withdraw_pull_belowTarget_sourcePartial_bestEffort() { + Test.reset(to: snapshot) + let user = setupUserWithPosition(1_000.0) + + // Drain most MOET from topUpSource, leaving only 5 + let receiver = Test.createAccount() + setupMoetVault(receiver, beFailed: false) + let userMoet = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + transferFungibleTokens(tokenIdentifier: MOET_TOKEN_IDENTIFIER, from: user, to: receiver, amount: userMoet - 5.0) + + withdrawFromPosition(signer: user, positionId: 0, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, amount: 50.0, pullFromTopUpSource: true) + + let health = getPositionHealth(pid: 0, beFailed: false) + Test.assert(health >= INT_MIN_HEALTH, message: "Should be >= minHealth but was ".concat(health.toString())) + Test.assert(health < INT_TARGET_HEALTH, message: "Should be < targetHealth (best-effort) but was ".concat(health.toString())) +} + +/// Scenario 6 (RED): pull=true, health between min and target, no source → succeed (above minHealth). +access(all) +fun test_withdraw_pull_belowTarget_noSource_succeeds() { + Test.reset(to: snapshot) + // Create position without source, borrow 615 MOET (health ~1.3) + let user = setupUserWithBarePosition(1_000.0, borrow: 615.0) + + // Withdraw with pull=true. No source, but position stays above minHealth → should succeed. + let res = _executeTransaction( + "./transactions/position-manager/withdraw_from_position.cdc", + [1 as UInt64, FLOW_TOKEN_IDENTIFIER, 50.0, true], + user + ) + Test.expect(res, Test.beSucceeded()) + + Test.assert(getPositionHealth(pid: 1, beFailed: false) >= INT_MIN_HEALTH) +} + +/// Scenario 7: pull=true, breaches minHealth, source restores → succeed at targetHealth. +access(all) +fun test_withdraw_pull_breachesMin_sourceRestores_succeeds() { + Test.reset(to: snapshot) + let user = setupUserWithPosition(1_000.0) + + withdrawFromPosition(signer: user, positionId: 0, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, amount: 200.0, pullFromTopUpSource: true) + + Test.assert(equalWithinVariance(INT_TARGET_HEALTH, getPositionHealth(pid: 0, beFailed: false))) +} + +/// Scenario 8: pull=true, breaches minHealth, source insufficient → fail. +access(all) +fun test_withdraw_pull_breachesMin_sourceInsufficient_fails() { + Test.reset(to: snapshot) + let user = setupUserWithPosition(1_000.0) + + // Drain nearly all MOET + let receiver = Test.createAccount() + setupMoetVault(receiver, beFailed: false) + let userMoet = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + transferFungibleTokens(tokenIdentifier: MOET_TOKEN_IDENTIFIER, from: user, to: receiver, amount: userMoet - 1.0) + + let res = _executeTransaction( + "./transactions/position-manager/withdraw_from_position.cdc", + [0 as UInt64, FLOW_TOKEN_IDENTIFIER, 900.0, true], + user + ) + Test.expect(res, Test.beFailed()) +} + +// ============================================================ +// depositAndPush tests +// ============================================================ + +/// Scenario 9: push=false → succeed, no rebalance, health rises above target. +access(all) +fun test_deposit_noPush_noRebalance() { + Test.reset(to: snapshot) + let user = setupUserWithPosition(1_000.0) + mintFlow(to: user, amount: 100.0) + + depositToPosition(signer: user, positionID: 0, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + + Test.assert(getPositionHealth(pid: 0, beFailed: false) > INT_TARGET_HEALTH) +} + +/// Scenario 10: push=true, health still below targetHealth after deposit → succeed, nothing to push. +access(all) +fun test_deposit_push_belowTarget_succeeds() { + Test.reset(to: snapshot) + // Position without sink, heavily borrowed (health ~1.14). LP is pid 0, bare is pid 1. + let user = setupUserWithBarePosition(1_000.0, borrow: 700.0) + + mintFlow(to: user, amount: 5.0) + depositToPosition(signer: user, positionID: 1, amount: 5.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) + + Test.assert(getPositionHealth(pid: 1, beFailed: false) < INT_TARGET_HEALTH) +} + +/// Scenario 11: push=true, health above targetHealth, sink has capacity → restores targetHealth. +access(all) +fun test_deposit_push_aboveTarget_restoresTarget() { + Test.reset(to: snapshot) + let user = setupUserWithPosition(1_000.0) + mintFlow(to: user, amount: 100.0) + + depositToPosition(signer: user, positionID: 0, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) + + Test.assert(equalWithinVariance(INT_TARGET_HEALTH, getPositionHealth(pid: 0, beFailed: false))) +} + +/// Scenario 12: push=true, health above targetHealth, sink limited → best-effort. +access(all) +fun test_deposit_push_aboveTarget_sinkLimited_bestEffort() { + Test.reset(to: snapshot) + let user = setupUserWithPosition(1_000.0) + + // Deposit a very large amount — pool reserves may not have enough MOET to fully rebalance. + mintFlow(to: user, amount: 50_000.0) + depositToPosition(signer: user, positionID: 0, amount: 50_000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) + + Test.assert(getPositionHealth(pid: 0, beFailed: false) >= INT_TARGET_HEALTH) +} + +/// Scenario 13: push=true, health above targetHealth, no sink → succeed (deposit always works). +access(all) +fun test_deposit_push_aboveTarget_noSink_succeeds() { + Test.reset(to: snapshot) + // Position without sink, no debt + let user = setupUserWithBarePosition(1_000.0, borrow: 0.0) + + mintFlow(to: user, amount: 100.0) + depositToPosition(signer: user, positionID: 0, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) + + Test.assert(getPositionHealth(pid: 0, beFailed: false) > INT_TARGET_HEALTH) +} From 94bd94e3a98aa9dbde382f08844ccb2601fb7211 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Mon, 23 Mar 2026 09:42:07 -0700 Subject: [PATCH 5/8] adjust test assertions --- cadence/tests/withdraw_and_pull_deposit_and_push_test.cdc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cadence/tests/withdraw_and_pull_deposit_and_push_test.cdc b/cadence/tests/withdraw_and_pull_deposit_and_push_test.cdc index 36bb55df..32686aec 100644 --- a/cadence/tests/withdraw_and_pull_deposit_and_push_test.cdc +++ b/cadence/tests/withdraw_and_pull_deposit_and_push_test.cdc @@ -142,11 +142,11 @@ fun test_withdraw_pull_belowTarget_sourcePartial_bestEffort() { withdrawFromPosition(signer: user, positionId: 0, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, amount: 50.0, pullFromTopUpSource: true) let health = getPositionHealth(pid: 0, beFailed: false) - Test.assert(health >= INT_MIN_HEALTH, message: "Should be >= minHealth but was ".concat(health.toString())) + Test.assert(health > INT_MIN_HEALTH, message: "Should be > minHealth but was ".concat(health.toString())) Test.assert(health < INT_TARGET_HEALTH, message: "Should be < targetHealth (best-effort) but was ".concat(health.toString())) } -/// Scenario 6 (RED): pull=true, health between min and target, no source → succeed (above minHealth). +/// Scenario 6: pull=true, health between min and target, no source → succeed (above minHealth). access(all) fun test_withdraw_pull_belowTarget_noSource_succeeds() { Test.reset(to: snapshot) @@ -242,11 +242,11 @@ fun test_deposit_push_aboveTarget_sinkLimited_bestEffort() { Test.reset(to: snapshot) let user = setupUserWithPosition(1_000.0) - // Deposit a very large amount — pool reserves may not have enough MOET to fully rebalance. + // Deposit a very large amount — pool reserves will not have enough MOET to fully rebalance. mintFlow(to: user, amount: 50_000.0) depositToPosition(signer: user, positionID: 0, amount: 50_000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) - Test.assert(getPositionHealth(pid: 0, beFailed: false) >= INT_TARGET_HEALTH) + Test.assert(getPositionHealth(pid: 0, beFailed: false) > INT_TARGET_HEALTH) } /// Scenario 13: push=true, health above targetHealth, no sink → succeed (deposit always works). From 39164f80cb092b1e3f05cea4b9917dd0a073f9a6 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Tue, 24 Mar 2026 13:08:06 -0700 Subject: [PATCH 6/8] apply review suggestion --- cadence/contracts/FlowALPv0.cdc | 146 ++++++++++++-------------------- 1 file changed, 53 insertions(+), 93 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 01ace659..4745270e 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1269,88 +1269,26 @@ access(all) contract FlowALPv0 { let position = self._borrowPosition(pid: pid) let tokenState = self._borrowUpdatedTokenState(type: type) - // Global interest indices are updated via tokenState() helper - - // Preflight to see if the funds are available - let topUpSource = position.borrowTopUpSource() - let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() - - // Compute the deposit required to maintain minHealth — the hard requirement. - let minHealthDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( - pid: pid, - depositType: topUpType, - targetHealth: position.getMinHealth(), - withdrawType: type, - withdrawAmount: amount - ) - - // When pullFromTopUpSource is true, also check whether a deposit is needed - // to maintain targetHealth (consistent with depositAndPush behaviour). - let targetHealthDeposit = pullFromTopUpSource - ? self.fundsRequiredForTargetHealthAfterWithdrawing( + if pullFromTopUpSource { + let topUpSource = position.borrowTopUpSource() + let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() + let targetHealthDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( pid: pid, depositType: topUpType, targetHealth: position.getTargetHealth(), withdrawType: type, withdrawAmount: amount ) - : 0.0 - - var canWithdraw = false - - if minHealthDeposit == 0.0 && targetHealthDeposit == 0.0 { - // No top-up needed: position stays above targetHealth (or minHealth when not pulling) - canWithdraw = true - } else if pullFromTopUpSource { - // Try to pull from topUpSource to restore targetHealth (best-effort). if let topUpSource = topUpSource { - let idealDeposit = targetHealthDeposit > 0.0 ? targetHealthDeposit : minHealthDeposit - - let pulledVault <- topUpSource.withdrawAvailable(maxAmount: idealDeposit) + let pulledVault <- topUpSource.withdrawAvailable(maxAmount: targetHealthDeposit) assert(pulledVault.getType() == topUpType, message: "topUpSource returned unexpected token type") - let pulledAmount = pulledVault.balance - - // NOTE: We requested the "ideal" deposit (targetHealth), but the hard requirement - // is minHealth. The top up source may not have enough to reach targetHealth, - // but the withdrawal can proceed as long as we stay above minHealth. - if pulledAmount >= minHealthDeposit { - self._depositEffectsOnly( - pid: pid, - from: <-pulledVault - ) - canWithdraw = true - } else { - // We can't get the funds required to service this withdrawal, so we need to redeposit what we got - self._depositEffectsOnly( - pid: pid, - from: <-pulledVault - ) - } - } - // If no source is configured (or pull was insufficient), the withdrawal - // can still proceed as long as the position stays above minHealth. - if !canWithdraw && minHealthDeposit == 0.0 { - canWithdraw = true - } - } - - if !canWithdraw { - // Log detailed information about the failed withdrawal (only if debugging enabled) - if self.config.isDebugLogging() { - let availableBalance = self.availableBalance(pid: pid, type: type, pullFromTopUpSource: false) - log(" [CONTRACT] WITHDRAWAL FAILED:") - log(" [CONTRACT] Position ID: \(pid)") - log(" [CONTRACT] Token type: \(type.identifier)") - log(" [CONTRACT] Requested amount: \(amount)") - log(" [CONTRACT] Available balance (without topUp): \(availableBalance)") - log(" [CONTRACT] Required deposit for minHealth: \(minHealthDeposit)") - log(" [CONTRACT] Pull from topUpSource: \(pullFromTopUpSource)") + self._depositEffectsOnly( + pid: pid, + from: <-pulledVault + ) } - // We can't service this withdrawal, so we just abort - panic("Cannot withdraw \(amount) of \(type.identifier) from position ID \(pid) - Insufficient funds for withdrawal") } - // If this position doesn't currently have an entry for this token, create one. if position.getBalance(type) == nil { position.setBalance(type, FlowALPModels.InternalBalance( direction: FlowALPModels.BalanceDirection.Credit, @@ -1358,38 +1296,42 @@ access(all) contract FlowALPv0 { )) } - let reserveVault = self.state.borrowReserve(type)! - // Reflect the withdrawal in the position's balance let uintAmount = UFix128(amount) position.borrowBalance(type)!.recordWithdrawal( 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, - 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. - // Since most positions will have a single token type, we're okay with this for now. - let positionView = self.buildPositionView(pid: pid) - let remainingBalance = positionView.trueBalance(ofToken: type) + // Safety checks! + self._assertMinimumBalanceAfterWithdrawal(type: type, position: position, tokenState: tokenState) - // 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), - message: "Withdrawal would leave position below minimum balance requirement of \(self.state.getTokenState(type)!.getMinimumTokenBalancePerPosition()). Remaining balance would be \(remainingBalance)." - ) + let postHealth = self.positionHealth(pid: pid) + if postHealth < position.getMinHealth() { + if self.config.isDebugLogging() { + let topUpType = position.borrowTopUpSource()?.getSourceType() ?? self.state.getDefaultToken() + let minHealthDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( + pid: pid, + depositType: topUpType, + targetHealth: position.getMinHealth(), + withdrawType: type, + withdrawAmount: amount + ) + let availableBalance = self.availableBalance(pid: pid, type: type, pullFromTopUpSource: false) + log(" [CONTRACT] WITHDRAWAL FAILED:") + log(" [CONTRACT] Position ID: \(pid)") + log(" [CONTRACT] Token type: \(type.identifier)") + log(" [CONTRACT] Requested amount: \(amount)") + log(" [CONTRACT] Available balance (without topUp): \(availableBalance)") + log(" [CONTRACT] Required deposit for minHealth: \(minHealthDeposit)") + log(" [CONTRACT] Pull from topUpSource: \(pullFromTopUpSource)") + } + // We can't service this withdrawal, so we just abort + panic("Cannot withdraw \(amount) of \(type.identifier) from position ID \(pid) - Insufficient funds for withdrawal") + } - // Queue for update if necessary self._queuePositionForUpdateIfNecessary(pid: pid) - + let reserveVault = self.state.borrowReserve(type)! let withdrawn <- reserveVault.withdraw(amount: amount) FlowALPEvents.emitWithdrawn( @@ -1404,6 +1346,24 @@ access(all) contract FlowALPv0 { return <- withdrawn } + /// Asserts that the remaining balance of `type` meets the minimum per-position requirement + /// (or is exactly zero). Panics with a descriptive message if not satisfied. + access(self) view fun _assertMinimumBalanceAfterWithdrawal( + type: Type, + position: &{FlowALPModels.InternalPosition}, + tokenState: &{FlowALPModels.TokenState} + ) { + let bal = position.getBalance(type) + let remainingBalance: UFix128 = bal == nil ? 0.0 + : bal!.direction == FlowALPModels.BalanceDirection.Credit + ? FlowALPMath.scaledBalanceToTrueBalance(bal!.scaledBalance, interestIndex: tokenState.getCreditInterestIndex()) + : FlowALPMath.scaledBalanceToTrueBalance(bal!.scaledBalance, interestIndex: tokenState.getDebitInterestIndex()) + assert( + remainingBalance == 0.0 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), + message: "Withdrawal would leave position below minimum balance requirement of \(self.state.getTokenState(type)!.getMinimumTokenBalancePerPosition()). Remaining balance would be \(remainingBalance)." + ) + } + /////////////////////// // POOL MANAGEMENT /////////////////////// From 87e9c25f49f6305cb9dfec5e405a49632ec8c5c5 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 25 Mar 2026 09:23:56 -0700 Subject: [PATCH 7/8] docs, naming --- cadence/contracts/FlowALPv0.cdc | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 4745270e..09977875 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1270,18 +1270,21 @@ access(all) contract FlowALPv0 { let tokenState = self._borrowUpdatedTokenState(type: type) if pullFromTopUpSource { - let topUpSource = position.borrowTopUpSource() - let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() - let targetHealthDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( - pid: pid, - depositType: topUpType, - targetHealth: position.getTargetHealth(), - withdrawType: type, - withdrawAmount: amount - ) - if let topUpSource = topUpSource { + if let topUpSource = position.borrowTopUpSource() { + // NOTE: getSourceType can lie, but we are resilient to this because: + // (1) we check the vault type returned by the source matches its purported type, below + // (2) computing target health deposit below will panic if the purported type is not supported + let purportedTopUpType = topUpSource.getSourceType() + let targetHealthDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( + pid: pid, + depositType: purportedTopUpType, + targetHealth: position.getTargetHealth(), + withdrawType: type, + withdrawAmount: amount + ) + let pulledVault <- topUpSource.withdrawAvailable(maxAmount: targetHealthDeposit) - assert(pulledVault.getType() == topUpType, message: "topUpSource returned unexpected token type") + assert(pulledVault.getType() == purportedTopUpType, message: "topUpSource returned unexpected token type") self._depositEffectsOnly( pid: pid, from: <-pulledVault @@ -1297,9 +1300,8 @@ access(all) contract FlowALPv0 { } // Reflect the withdrawal in the position's balance - let uintAmount = UFix128(amount) position.borrowBalance(type)!.recordWithdrawal( - amount: uintAmount, + amount: UFix128(amount), tokenState: tokenState ) From 713b289a4f77ee12d8bbf735339176b581afa924 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 25 Mar 2026 09:56:58 -0700 Subject: [PATCH 8/8] refactor min balance req --- cadence/contracts/FlowALPModels.cdc | 1 + cadence/contracts/FlowALPv0.cdc | 42 +++++++++++++++++++---------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/cadence/contracts/FlowALPModels.cdc b/cadence/contracts/FlowALPModels.cdc index ca5b8359..cdc8f481 100644 --- a/cadence/contracts/FlowALPModels.cdc +++ b/cadence/contracts/FlowALPModels.cdc @@ -394,6 +394,7 @@ access(all) contract FlowALPModels { } /// Returns the true balance for the given internal (scaled) balance, accounting for accrued interest. + /// TODO: This could be view, but Cadence seems to interpret constructing a Balance as modifying state access(all) fun trueBalance(balance: InternalBalance): Balance { let scaled = balance.getScaledBalance() let interestIndex = scaled.direction == BalanceDirection.Credit diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 519e7f32..19a9effa 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -723,6 +723,20 @@ access(all) contract FlowALPv0 { ) } + // Builds a up-to-date TokenSnapshot instance for the given type. + access(self) fun buildTokenSnapshot(type: Type): FlowALPModels.TokenSnapshot { + let tokenState = self._borrowUpdatedTokenState(type: type) + return FlowALPModels.TokenSnapshot( + price: UFix128(self.config.getPriceOracle().price(ofToken: type)!), + credit: tokenState.getCreditInterestIndex(), + debit: tokenState.getDebitInterestIndex(), + risk: FlowALPModels.RiskParamsImplv1( + collateralFactor: UFix128(self.config.getCollateralFactor(tokenType: type)), + borrowFactor: UFix128(self.config.getBorrowFactor(tokenType: type)) + ) + ) + } + /// Computes the deposit amount needed to bring effective values to the target health. /// Accounts for whether the deposit reduces debt or adds collateral based on the /// position's current balance direction for the deposit token. @@ -960,6 +974,7 @@ access(all) contract FlowALPv0 { !self.isPaused(): "Withdrawal, deposits, and liquidations are paused by governance" self.state.getTokenState(funds.getType()) != nil: "Invalid token type \(funds.getType().identifier) - not supported by this Pool" + funds.balance > 0.0: "Cannot create a position without deposit" self.positionSatisfiesMinimumBalance(type: funds.getType(), balance: UFix128(funds.balance)): "Insufficient funds to create position. Minimum deposit of \(funds.getType().identifier) is \(self.state.getTokenState(funds.getType())!.getMinimumTokenBalancePerPosition())" // TODO(jord): Sink/source should be valid @@ -1014,12 +1029,13 @@ access(all) contract FlowALPv0 { /// This function is used to validate that positions maintain a minimum balance to prevent /// dust positions and ensure operational efficiency. The minimum requirement applies to /// credit (deposit) balances and is enforced at position creation and during withdrawals. + /// Zero balances are always allowed. /// /// @param type: The token type to check (e.g., Type<@FlowToken.Vault>()) - /// @param balance: The balance amount to validate + /// @param balance: The (true) balance amount to validate /// @return true if the balance meets or exceeds the minimum requirement, false otherwise access(self) view fun positionSatisfiesMinimumBalance(type: Type, balance: UFix128): Bool { - return balance >= UFix128(self.state.getTokenState(type)!.getMinimumTokenBalancePerPosition()) + return balance == 0.0 || balance >= UFix128(self.state.getTokenState(type)!.getMinimumTokenBalancePerPosition()) } /// Allows anyone to deposit funds into any position. @@ -1219,6 +1235,7 @@ access(all) contract FlowALPv0 { // Get a reference to the user's position and global token state for the affected token. let position = self._borrowPosition(pid: pid) let tokenState = self._borrowUpdatedTokenState(type: type) + let tokenSnapshot = self.buildTokenSnapshot(type: type) if pullFromTopUpSource { if let topUpSource = position.borrowTopUpSource() { @@ -1257,7 +1274,7 @@ access(all) contract FlowALPv0 { ) // Safety checks! - self._assertMinimumBalanceAfterWithdrawal(type: type, position: position, tokenState: tokenState) + self._assertPositionSatisfiesMinimumBalance(type: type, position: position, tokenSnapshot: tokenSnapshot) let postHealth = self.positionHealth(pid: pid) if postHealth < position.getMinHealth() { @@ -1301,20 +1318,17 @@ access(all) contract FlowALPv0 { /// Asserts that the remaining balance of `type` meets the minimum per-position requirement /// (or is exactly zero). Panics with a descriptive message if not satisfied. - access(self) view fun _assertMinimumBalanceAfterWithdrawal( + access(self) fun _assertPositionSatisfiesMinimumBalance( type: Type, position: &{FlowALPModels.InternalPosition}, - tokenState: &{FlowALPModels.TokenState} + tokenSnapshot: FlowALPModels.TokenSnapshot ) { - let bal = position.getBalance(type) - let remainingBalance: UFix128 = bal == nil ? 0.0 - : bal!.direction == FlowALPModels.BalanceDirection.Credit - ? FlowALPMath.scaledBalanceToTrueBalance(bal!.scaledBalance, interestIndex: tokenState.getCreditInterestIndex()) - : FlowALPMath.scaledBalanceToTrueBalance(bal!.scaledBalance, interestIndex: tokenState.getDebitInterestIndex()) - assert( - remainingBalance == 0.0 || self.positionSatisfiesMinimumBalance(type: type, balance: remainingBalance), - message: "Withdrawal would leave position below minimum balance requirement of \(self.state.getTokenState(type)!.getMinimumTokenBalancePerPosition()). Remaining balance would be \(remainingBalance)." - ) + if let bal = position.getBalance(type) { + let trueBal = tokenSnapshot.trueBalance(balance: bal) + assert( + self.positionSatisfiesMinimumBalance(type: type, balance: trueBal.quantity), + message: "Withdrawal would leave position below minimum balance requirement of \(self.state.getTokenState(type)!.getMinimumTokenBalancePerPosition()). Remaining balance would be \(trueBal.quantity).") + } } ///////////////////////