Skip to content

FLO-5: Consistent behaviour in withdrawAndPull/depositAndPush#286

Open
jordanschalm wants to merge 13 commits intobalance-sheet-health-statement-refactorfrom
jord/flo-5
Open

FLO-5: Consistent behaviour in withdrawAndPull/depositAndPush#286
jordanschalm wants to merge 13 commits intobalance-sheet-health-statement-refactorfrom
jord/flo-5

Conversation

@jordanschalm
Copy link
Member

@jordanschalm jordanschalm commented Mar 20, 2026

Closes #214

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.

jordanschalm and others added 6 commits March 19, 2026 19:23
…rce 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…thinVariance

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) <noreply@anthropic.com>
…-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) <noreply@anthropic.com>
@jordanschalm jordanschalm marked this pull request as ready for review March 23, 2026 16:43
@jordanschalm jordanschalm requested a review from a team as a code owner March 23, 2026 16:43
Copy link
Member

@holyfuchs holyfuchs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use this PR to clean this function up a bit.

The current implementation is using:
computationUsed (pull=true, no pull needed): 163
computationUsed (pull=true, pull executed): 199
computationUsed (pull=false): 128

The suggestion:
computationUsed (pull=true, no pull needed): 114
computationUsed (pull=true, pull executed): 136
computationUsed (pull=false): 64

effectively cutting execution used on pull=false by 50%.


self.unlockPosition(pid)
return <- withdrawn
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pre {
!self.isPausedOrWarmup(): "Withdrawals are paused by governance"
self.positions[pid] != nil:
"Invalid position ID \(pid) - could not find an InternalPosition with the requested ID in the Pool"
self.state.getTokenState(type) != nil:
"Invalid token type \(type.identifier) - not supported by this Pool"
}
post {
!self.state.isPositionLocked(pid): "Position is not unlocked"
}
self.lockPosition(pid)
if self.config.isDebugLogging() {
log(" [CONTRACT] withdrawAndPull(pid: \(pid), type: \(type.identifier), amount: \(amount), pullFromTopUpSource: \(pullFromTopUpSource))")
}
if amount == 0.0 {
self.unlockPosition(pid)
return <- DeFiActionsUtils.getEmptyVault(type)
}
// 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)
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 {
let pulledVault <- topUpSource.withdrawAvailable(maxAmount: targetHealthDeposit)
assert(pulledVault.getType() == topUpType, message: "topUpSource returned unexpected token type")
self._depositEffectsOnly(
pid: pid,
from: <-pulledVault
)
}
}
if position.getBalance(type) == nil {
position.setBalance(type, FlowALPModels.InternalBalance(
direction: FlowALPModels.BalanceDirection.Credit,
scaledBalance: 0.0
))
}
// Reflect the withdrawal in the position's balance
let uintAmount = UFix128(amount)
position.borrowBalance(type)!.recordWithdrawal(
amount: uintAmount,
tokenState: tokenState
)
// Safety checks!
self._assertMinimumBalanceAfterWithdrawal(type: type, position: position, tokenState: tokenState)
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")
}
self._queuePositionForUpdateIfNecessary(pid: pid)
let reserveVault = self.state.borrowReserve(type)!
let withdrawn <- reserveVault.withdraw(amount: amount)
FlowALPEvents.emitWithdrawn(
pid: pid,
poolUUID: self.uuid,
vaultType: type,
amount: withdrawn.balance,
withdrawnUUID: withdrawn.uuid
)
self.unlockPosition(pid)
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)."
)
}

@jordanschalm jordanschalm changed the base branch from main to balance-sheet-health-statement-refactor March 25, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FLO-5: Inconsistent Flag Behavior: pullFromTopUpSource Bypasses Rebalancing While pushToDrawDownSink Forces It

2 participants