Skip to content

Commit 74b736f

Browse files
jordanschalmclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 6706158 commit 74b736f

2 files changed

Lines changed: 114 additions & 19 deletions

File tree

cadence/contracts/FlowALPv0.cdc

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,8 +1237,8 @@ access(all) contract FlowALPv0 {
12371237
/// Withdraws the requested funds from the specified position
12381238
/// with the configurable `pullFromTopUpSource` option.
12391239
///
1240-
/// If `pullFromTopUpSource` is true, deficient value putting the position below its min health
1241-
/// is pulled from the position's configured `topUpSource`.
1240+
/// If `pullFromTopUpSource` is true, any deficiency below the position's target health
1241+
/// is pulled from the position's configured `topUpSource` (consistent with depositAndPush).
12421242
/// TODO(jord): ~150-line function - consider refactoring.
12431243
access(FlowALPModels.EPosition) fun withdrawAndPull(
12441244
pid: UInt64,
@@ -1275,40 +1275,46 @@ access(all) contract FlowALPv0 {
12751275
let topUpSource = position.borrowTopUpSource()
12761276
let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken()
12771277

1278-
let requiredDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing(
1278+
// Compute the deposit required to maintain minHealth — the hard requirement.
1279+
let minHealthDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing(
12791280
pid: pid,
12801281
depositType: topUpType,
12811282
targetHealth: position.getMinHealth(),
12821283
withdrawType: type,
12831284
withdrawAmount: amount
12841285
)
12851286

1287+
// When pullFromTopUpSource is true, also check whether a deposit is needed
1288+
// to maintain targetHealth (consistent with depositAndPush behaviour).
1289+
let targetHealthDeposit = pullFromTopUpSource
1290+
? self.fundsRequiredForTargetHealthAfterWithdrawing(
1291+
pid: pid,
1292+
depositType: topUpType,
1293+
targetHealth: position.getTargetHealth(),
1294+
withdrawType: type,
1295+
withdrawAmount: amount
1296+
)
1297+
: 0.0
1298+
12861299
var canWithdraw = false
12871300

1288-
if requiredDeposit == 0.0 {
1289-
// We can service this withdrawal without any top up
1301+
if minHealthDeposit == 0.0 && targetHealthDeposit == 0.0 {
1302+
// No top-up needed: position stays above targetHealth (or minHealth when not pulling)
12901303
canWithdraw = true
12911304
} else if pullFromTopUpSource {
12921305
// We need more funds to service this withdrawal, see if they are available from the top up source
12931306
if let topUpSource = topUpSource {
1294-
// If we have to rebalance, let's try to rebalance to the target health, not just the minimum
1295-
let idealDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing(
1296-
pid: pid,
1297-
depositType: topUpType,
1298-
targetHealth: position.getTargetHealth(),
1299-
withdrawType: type,
1300-
withdrawAmount: amount
1301-
)
1307+
// Try to rebalance to target health
1308+
let idealDeposit = targetHealthDeposit > 0.0 ? targetHealthDeposit : minHealthDeposit
13021309

13031310
let pulledVault <- topUpSource.withdrawAvailable(maxAmount: idealDeposit)
13041311
assert(pulledVault.getType() == topUpType, message: "topUpSource returned unexpected token type")
13051312
let pulledAmount = pulledVault.balance
13061313

1307-
1308-
// NOTE: We requested the "ideal" deposit, but we compare against the required deposit here.
1309-
// The top up source may not have enough funds get us to the target health, but could have
1310-
// enough to keep us over the minimum.
1311-
if pulledAmount >= requiredDeposit {
1314+
// NOTE: We requested the "ideal" deposit (targetHealth), but the hard requirement
1315+
// is minHealth. The top up source may not have enough to reach targetHealth,
1316+
// but the withdrawal can proceed as long as we stay above minHealth.
1317+
if pulledAmount >= minHealthDeposit {
13121318
// We can service this withdrawal if we deposit funds from our top up source
13131319
self._depositEffectsOnly(
13141320
pid: pid,
@@ -1334,7 +1340,7 @@ access(all) contract FlowALPv0 {
13341340
log(" [CONTRACT] Token type: \(type.identifier)")
13351341
log(" [CONTRACT] Requested amount: \(amount)")
13361342
log(" [CONTRACT] Available balance (without topUp): \(availableBalance)")
1337-
log(" [CONTRACT] Required deposit for minHealth: \(requiredDeposit)")
1343+
log(" [CONTRACT] Required deposit for minHealth: \(minHealthDeposit)")
13381344
log(" [CONTRACT] Pull from topUpSource: \(pullFromTopUpSource)")
13391345
}
13401346
// We can't service this withdrawal, so we just abort
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import Test
2+
import BlockchainHelpers
3+
4+
import "MOET"
5+
import "test_helpers.cdc"
6+
7+
access(all) var snapshot: UInt64 = 0
8+
9+
access(all)
10+
fun setup() {
11+
deployContracts()
12+
13+
snapshot = getCurrentBlockHeight()
14+
}
15+
16+
/// Verifies that withdrawAndPull with pullFromTopUpSource=true rebalances
17+
/// the position back to targetHealth, not just minHealth.
18+
///
19+
/// Setup:
20+
/// - User deposits 1000 FLOW (price=1.0, CF=0.8) with auto-borrow.
21+
/// - Position starts at targetHealth=1.3 with ~615.38 MOET debt.
22+
/// - User's MOET vault (topUpSource) holds ~615.38 MOET.
23+
///
24+
/// Action:
25+
/// - Withdraw a small FLOW amount that drops health below targetHealth
26+
/// but keeps it above minHealth (1.1).
27+
/// - Use pullFromTopUpSource=true.
28+
///
29+
/// Expected (consistent with depositAndPush):
30+
/// - The protocol should pull from the topUpSource to rebalance
31+
/// back to targetHealth, not just leave the position between
32+
/// minHealth and targetHealth.
33+
access(all)
34+
fun test_withdrawAndPull_rebalancesToTargetHealth() {
35+
let initialPrice = 1.0
36+
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice)
37+
38+
createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false)
39+
addSupportedTokenZeroRateCurve(
40+
signer: PROTOCOL_ACCOUNT,
41+
tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER,
42+
collateralFactor: 0.8,
43+
borrowFactor: 1.0,
44+
depositRate: 1_000_000.0,
45+
depositCapacityCap: 1_000_000.0
46+
)
47+
48+
let user = Test.createAccount()
49+
setupMoetVault(user, beFailed: false)
50+
mintFlow(to: user, amount: 1_000.0)
51+
grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user)
52+
53+
// Open position with auto-borrow: deposits 1000 FLOW, borrows ~615.38 MOET.
54+
// Health starts at targetHealth (1.3).
55+
let openRes = executeTransaction(
56+
"../transactions/flow-alp/position/create_position.cdc",
57+
[1_000.0, FLOW_VAULT_STORAGE_PATH, true],
58+
user
59+
)
60+
Test.expect(openRes, Test.beSucceeded())
61+
62+
let healthBefore = getPositionHealth(pid: 0, beFailed: false)
63+
let tolerance: UFix128 = 0.01
64+
Test.assert(
65+
healthBefore >= INT_TARGET_HEALTH - tolerance && healthBefore <= INT_TARGET_HEALTH + tolerance,
66+
message: "Position should start at target health (~1.3) but was ".concat(healthBefore.toString())
67+
)
68+
69+
// Withdraw 50 FLOW with pullFromTopUpSource=true.
70+
// Without the fix: health drops below targetHealth but stays above minHealth,
71+
// so pullFromTopUpSource is ignored and the position is NOT rebalanced.
72+
// With the fix: the protocol should pull from topUpSource to restore targetHealth.
73+
withdrawFromPosition(
74+
signer: user,
75+
positionId: 0,
76+
tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER,
77+
amount: 50.0,
78+
pullFromTopUpSource: true
79+
)
80+
81+
let healthAfter = getPositionHealth(pid: 0, beFailed: false)
82+
83+
// The position health should be restored to targetHealth (1.3),
84+
// NOT left between minHealth and targetHealth.
85+
Test.assert(
86+
healthAfter >= INT_TARGET_HEALTH - tolerance,
87+
message: "With pullFromTopUpSource=true, position should be rebalanced to target health (~1.3) but health was ".concat(healthAfter.toString())
88+
)
89+
}

0 commit comments

Comments
 (0)