Skip to content

Commit 4f39c69

Browse files
authored
Merge pull request #290 from onflow/UlianaAndrukhiv/280-rebalancing-failures
Rebalancing Failures
2 parents 1170ab1 + 1c52f80 commit 4f39c69

8 files changed

+377
-31
lines changed

cadence/tests/interest_accrual_integration_test.cdc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,8 @@ fun test_moet_debit_accrues_interest() {
271271
// Rebalance persists interest accrual to storage and may auto-repay debt
272272
// to restore health. We use pre-rebalance values for assertions since
273273
// rebalance can modify debt amounts.
274-
rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: borrowerPid, force: true, beFailed: false)
275-
274+
let rebalanceRes= rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: borrowerPid, force: true)
275+
Test.expect(rebalanceRes, Test.beSucceeded())
276276
let detailsAfter = getPositionDetails(pid: borrowerPid, beFailed: false)
277277
let healthAfter = detailsAfter.health
278278
let debtAfter = getDebitBalanceForType(details: detailsAfter, vaultType: Type<@MOET.Vault>())

cadence/tests/platform_integration_test.cdc

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ fun testUndercollateralizedPositionRebalanceSucceeds() {
136136

137137
// rebalance should pull from the topUpSource, decreasing the MOET in the user's Vault since we use a VaultSource
138138
// as a topUpSource when opening the Position
139-
rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true, beFailed: false)
139+
let rebalanceRes = rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true)
140+
Test.expect(rebalanceRes, Test.beSucceeded())
140141

141142
let moetBalanceAfterRebalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
142143
let healthAfterRebalance = getPositionHealth(pid: 0, beFailed: false)
@@ -203,7 +204,8 @@ fun testOvercollateralizedPositionRebalanceSucceeds() {
203204

204205
// rebalance should pull from the topUpSource, decreasing the MOET in the user's Vault since we use a VaultSource
205206
// as a topUpSource when opening the Position
206-
rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true, beFailed: false)
207+
let rebalanceRes = rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true)
208+
Test.expect(rebalanceRes, Test.beSucceeded())
207209

208210
let moetBalanceAfterRebalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
209211
let healthAfterRebalance = getPositionHealth(pid: 0, beFailed: false)
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import Test
2+
import BlockchainHelpers
3+
4+
import "FlowALPv0"
5+
import "MOET"
6+
7+
import "test_helpers.cdc"
8+
9+
access(all) var snapshot: UInt64 = 0
10+
11+
access(all)
12+
fun safeReset() {
13+
let cur = getCurrentBlockHeight()
14+
if cur > snapshot {
15+
Test.reset(to: snapshot)
16+
}
17+
}
18+
19+
access(all)
20+
fun setup() {
21+
deployContracts()
22+
23+
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0)
24+
createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false)
25+
addSupportedTokenZeroRateCurve(
26+
signer: PROTOCOL_ACCOUNT,
27+
tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER,
28+
collateralFactor: 0.8,
29+
borrowFactor: 1.0,
30+
depositRate: 1_000_000.0,
31+
depositCapacityCap: 1_000_000.0
32+
)
33+
34+
// DEX swapper for FLOW → MOET (price 1:1, matches oracle)
35+
setMockDexPriceForPair(
36+
signer: PROTOCOL_ACCOUNT,
37+
inVaultIdentifier: FLOW_TOKEN_IDENTIFIER,
38+
outVaultIdentifier: MOET_TOKEN_IDENTIFIER,
39+
vaultSourceStoragePath: MOET.VaultStoragePath,
40+
priceRatio: 1.0
41+
)
42+
43+
snapshot = getCurrentBlockHeight()
44+
}
45+
46+
/// ============================================================
47+
/// Malicious topUpSource leads to liquidation
48+
///
49+
/// Simulates a topUpSource that provides no funds, preventing rebalancing
50+
/// after the position becomes undercollateralized. The position
51+
/// remains liquidatable and is successfully liquidated.
52+
/// ============================================================
53+
access(all)
54+
fun testRebalance_MaliciousTopUpSource_EnablesLiquidation() {
55+
safeReset()
56+
57+
let user = Test.createAccount()
58+
setupMoetVault(user, beFailed: false)
59+
let mintRes = mintFlow(to: user, amount: 1_000.0)
60+
Test.expect(mintRes, Test.beSucceeded())
61+
createPosition(admin: PROTOCOL_ACCOUNT, signer: user, amount: 1_000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true)
62+
63+
// completely empty the topUpSource so that any withdrawal returns 0
64+
let drain = Test.createAccount()
65+
setupMoetVault(drain, beFailed: false)
66+
let userMoet = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
67+
transferFungibleTokens(
68+
tokenIdentifier: MOET_TOKEN_IDENTIFIER,
69+
from: user,
70+
to: drain,
71+
amount: userMoet // all amount
72+
)
73+
74+
// crash price so health falls below 1.0
75+
let crashPrice = 0.5
76+
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: crashPrice)
77+
setMockDexPriceForPair(
78+
signer: PROTOCOL_ACCOUNT,
79+
inVaultIdentifier: FLOW_TOKEN_IDENTIFIER,
80+
outVaultIdentifier: MOET_TOKEN_IDENTIFIER,
81+
vaultSourceStoragePath: MOET.VaultStoragePath,
82+
priceRatio: crashPrice
83+
)
84+
85+
Test.assert(getPositionHealth(pid: 0, beFailed: false) < 1.0, message: "Position must be liquidatable after price crash")
86+
87+
// rebalance attempt should fail cause source has 0 MOET
88+
let rebalanceRes = rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true)
89+
Test.expect(rebalanceRes, Test.beFailed())
90+
Test.assertError(rebalanceRes, errorMessage: "topUpSource insufficient to save position from liquidation")
91+
92+
// position is still liquidatable
93+
Test.assert(getPositionHealth(pid: 0, beFailed: false) < 1.0,message: "Position should remain liquidatable after failed rebalance",)
94+
95+
let liquidator = Test.createAccount()
96+
setupMoetVault(liquidator, beFailed: false)
97+
mintMoet(signer: PROTOCOL_ACCOUNT, to: liquidator.address, amount: 1_000.0, beFailed: false)
98+
99+
let repayAmount = 100.0
100+
let seizeAmount = 150.0
101+
102+
let collateralPreLiq = getPositionBalance(pid: 0, vaultID: FLOW_TOKEN_IDENTIFIER).balance
103+
let debtPreLiq = getPositionBalance(pid: 0, vaultID: MOET_TOKEN_IDENTIFIER).balance
104+
let liqMoetBefore = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath)!
105+
106+
let liqRes = manualLiquidation(
107+
signer: liquidator,
108+
pid: 0,
109+
debtVaultIdentifier: Type<@MOET.Vault>().identifier,
110+
seizeVaultIdentifier: FLOW_TOKEN_IDENTIFIER,
111+
seizeAmount: seizeAmount,
112+
repayAmount: repayAmount
113+
)
114+
Test.expect(liqRes, Test.beSucceeded())
115+
116+
// position lost exactly the liquidated amounts
117+
let collateralPostLiq = getPositionBalance(pid: 0, vaultID: FLOW_TOKEN_IDENTIFIER).balance
118+
let debtPostLiq = getPositionBalance(pid: 0, vaultID: MOET_TOKEN_IDENTIFIER).balance
119+
Test.assertEqual(collateralPostLiq, collateralPreLiq - seizeAmount)
120+
Test.assertEqual(debtPostLiq, debtPreLiq - repayAmount)
121+
122+
// liquidator spent MOET and received FLOW
123+
let liqMoetAfter = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath)!
124+
let liqFlowAfter = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenBalance)!
125+
Test.assertEqual(liqMoetBefore - liqMoetAfter, repayAmount)
126+
Test.assertEqual(liqFlowAfter, seizeAmount)
127+
}
128+
129+
/// ============================================================
130+
/// Rebalance skipped due to DrawDownSink rejection
131+
///
132+
/// Simulates an overcollateralised position where rebalance attempts
133+
/// to push surplus funds to the drawDownSink, but the sink cannot
134+
/// accept cause was removed
135+
/// ============================================================
136+
access(all)
137+
fun testRebalance_DrawDownSinkRejection() {
138+
safeReset()
139+
140+
let user = Test.createAccount()
141+
setupMoetVault(user, beFailed: false)
142+
transferFlowTokens(to: user, amount: 1_000.0)
143+
144+
createPosition(
145+
admin: PROTOCOL_ACCOUNT,
146+
signer: user,
147+
amount: 1_000.0,
148+
vaultStoragePath: FLOW_VAULT_STORAGE_PATH,
149+
pushToDrawDownSink: true
150+
)
151+
152+
let initialDebt = getPositionBalance(pid: 0, vaultID: MOET_TOKEN_IDENTIFIER).balance
153+
let healthBeforePriceChange = getPositionHealth(pid: 0, beFailed: false)
154+
155+
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.5)
156+
157+
// price increase, position even more overcollateralised
158+
let healthAfterPrice = getPositionHealth(pid: 0, beFailed: false)
159+
Test.assert(healthAfterPrice >= INT_MAX_HEALTH, message: "Position should be overcollateralized after price increase, health=\(healthAfterPrice.toString())")
160+
161+
let moetInVaultBeforeRebalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
162+
163+
// remove the drawDownSink, so rebalance cannot push surplus to drawDownSink
164+
let setSinkRes = setDrawDownSink(signer: user, pid: 0, sink: nil)
165+
Test.expect(setSinkRes, Test.beSucceeded())
166+
167+
let rebalanceRes = rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true)
168+
Test.expect(rebalanceRes, Test.beSucceeded())
169+
170+
let healthAfterRebalance = getPositionHealth(pid: 0, beFailed: false)
171+
let moetInVaultAfterRebalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
172+
173+
// debt and health stay the same
174+
Test.assertEqual(moetInVaultAfterRebalance, moetInVaultBeforeRebalance)
175+
let debtAfterRebalance = getPositionBalance(pid: 0, vaultID: MOET_TOKEN_IDENTIFIER).balance
176+
Test.assertEqual(initialDebt, debtAfterRebalance)
177+
Test.assert(healthAfterRebalance >= INT_TARGET_HEALTH, message: "Health should remain above targetHealth when sink is removed (health=\(healthAfterRebalance.toString()))")
178+
Test.assertEqual(healthAfterRebalance, healthAfterPrice)
179+
}
180+
181+
/// ============================================================
182+
/// Rebalance exceeds gas limits for large position set
183+
///
184+
/// Simulates many overcollateralised positions requiring rebalance.
185+
/// Since asyncUpdate processes a limited batch per call, attempting
186+
/// to handle too many positions in one transaction exceeds the
187+
/// computation limit and fails.
188+
/// ============================================================
189+
access(all)
190+
fun testRebalance_AsyncUpdate_ProcessesAtMostConfiguredBatchSize() {
191+
safeReset()
192+
193+
// open positions so they land in the update queue
194+
let numPositions = 150
195+
var pid: UInt64 = 0
196+
while pid < UInt64(numPositions) {
197+
let user = Test.createAccount()
198+
setupMoetVault(user, beFailed: false)
199+
let mintRes = mintFlow(to: user, amount: 1_000.0)
200+
Test.expect(mintRes, Test.beSucceeded())
201+
createPosition(
202+
admin: PROTOCOL_ACCOUNT,
203+
signer: user,
204+
amount: 1_000.0,
205+
vaultStoragePath: FLOW_VAULT_STORAGE_PATH,
206+
pushToDrawDownSink: true
207+
)
208+
pid = pid + 1
209+
}
210+
211+
// raise price: all positions overcollateralised
212+
// effectiveCollateral = 1000 × 1.2 × 0.8 = 960
213+
// effectiveDebt ≈ 615.38
214+
// health ≈ 1.56 > maxHealth (1.5)
215+
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.2)
216+
217+
// try to asyncUpdate for rebalancing positions back toward targetHealth (1.3)
218+
let asyncUpdateRes = asyncUpdate()
219+
Test.expect(asyncUpdateRes, Test.beFailed())
220+
Test.assertError(asyncUpdateRes, errorMessage: "computation exceeds limit")
221+
222+
// all positions should have not been processed
223+
var i: UInt64 = 0
224+
while i < UInt64(numPositions) {
225+
let h = getPositionHealth(pid: i, beFailed: false)
226+
Test.assert(h > INT_MAX_HEALTH, message: "Position \(i.toString()) should be overcollateralised")
227+
i = i + 1
228+
}
229+
}
230+
231+
/// ============================================================
232+
/// Shared liquidity source across positions
233+
///
234+
/// Two positions share the same topUpSource. After a price drop, only one can
235+
/// be rebalanced due to limited funds; the first succeeds, the second fails
236+
/// and remains liquidatable.
237+
/// ============================================================
238+
access(all)
239+
fun testRebalance_ConcurrentRebalances() {
240+
safeReset()
241+
242+
let user = Test.createAccount()
243+
let drain = Test.createAccount()
244+
245+
setupMoetVault(user, beFailed: false)
246+
setupMoetVault(drain, beFailed: false)
247+
248+
var mintRes = mintFlow(to: user, amount: 2_000.0)
249+
Test.expect(mintRes, Test.beSucceeded())
250+
251+
createPosition(admin: PROTOCOL_ACCOUNT, signer: user, amount: 1_000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true)
252+
createPosition(admin: PROTOCOL_ACCOUNT, signer: user, amount: 1_000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true)
253+
254+
// minHealth = 1.1: required deposit per position to reach minHealth after 50% price crash:
255+
// effectiveCollateral = 1 000 * 0.5 * 0.8 = 400
256+
// effectiveDebt ≈ 615.38461538
257+
//
258+
// health >= 1.0 (to avoid liquidation): 400 / (615.38461538 - required) = 1.0
259+
// Required MOET ≈ 215.38461538 MOET
260+
//
261+
// left 215.38 MOET which is enough for one position, not both
262+
let moetAmount = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
263+
transferFungibleTokens(tokenIdentifier: MOET_TOKEN_IDENTIFIER, from: user, to: drain, amount: moetAmount - 215.38461538)
264+
265+
// drop price so both positions fall below health 1.0
266+
// effectiveCollateral = 1000 * 0.5 * 0.8 = 400; debt ≈ 615 → health ≈ 0.65
267+
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.5)
268+
269+
Test.assert(getPositionHealth(pid: 0, beFailed: false) < 1.0, message: "Position should be undercollateralised")
270+
Test.assert(getPositionHealth(pid: 1, beFailed: false) < 1.0, message: "Position should be undercollateralised")
271+
let userMoetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
272+
273+
// first rebalance (position 0): user has 215.38461538 MOET — enough to rescue
274+
let rebalanceRes0 = rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true)
275+
Test.expect(rebalanceRes0, Test.beSucceeded())
276+
277+
let userMoetAfterFirst = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
278+
Test.assert(
279+
userMoetAfterFirst < userMoetBefore,
280+
message: "user's MOET should have decreased after first rebalance (before=\(userMoetBefore.toString()), after=\(userMoetAfterFirst.toString()))"
281+
)
282+
283+
let health0AfterFirst = getPositionHealth(pid: 0, beFailed: false)
284+
Test.assert(
285+
health0AfterFirst >= 1.0,
286+
message: "Position 0 should be healthy after first rebalance (health=\(health0AfterFirst.toString()))"
287+
)
288+
289+
// second rebalance (position 1): user has 0 MOET — not enough to rescue
290+
let rebalance1 = rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 1, force: true)
291+
Test.expect(rebalance1, Test.beFailed())
292+
Test.assertError(rebalance1, errorMessage: "topUpSource insufficient to save position from liquidation")
293+
294+
// position 1 remains undercollateralised and open for liquidation
295+
let health1AfterSecond = getPositionHealth(pid: 1, beFailed: false)
296+
Test.assert(
297+
health1AfterSecond < 1.0,
298+
message: "Position 1 should remain undercollateralised after failed second rebalance (health=\(health1AfterSecond.toString()))"
299+
)
300+
}

cadence/tests/rebalance_overcollateralised_test.cdc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ fun testRebalanceOvercollateralised() {
6565
Test.assert(healthAfterPriceChange >= INT_MAX_HEALTH,
6666
message: "Expected health after price increase to be >= 1.5 but got ".concat(healthAfterPriceChange.toString()))
6767

68-
rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true, beFailed: false)
68+
let rebalanceRes = rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true)
69+
Test.expect(rebalanceRes, Test.beSucceeded())
6970

7071
let healthAfterRebalance = getPositionHealth(pid: 0, beFailed: false)
7172

cadence/tests/rebalance_undercollateralised_test.cdc

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ fun testRebalanceUndercollateralised() {
3434
// user setup
3535
let user = Test.createAccount()
3636
setupMoetVault(user, beFailed: false)
37-
mintFlow(to: user, amount: 1_000.0)
37+
let mintRes = mintFlow(to: user, amount: 1_000.0)
38+
Test.expect(mintRes, Test.beSucceeded())
3839

3940
// Grant beta access to user so they can create positions
4041
grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user)
@@ -65,7 +66,8 @@ fun testRebalanceUndercollateralised() {
6566
let userMoetBalanceBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
6667
let healthAfterPriceChange = getPositionHealth(pid: 0, beFailed: false)
6768

68-
rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true, beFailed: false)
69+
let rebalanceRes = rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true)
70+
Test.expect(rebalanceRes, Test.beSucceeded())
6971

7072
let healthAfterRebalance = getPositionHealth(pid: 0, beFailed: false)
7173

@@ -135,7 +137,8 @@ fun testRebalanceUndercollateralised_InsufficientTopUpSource() {
135137

136138
let user = Test.createAccount()
137139
setupMoetVault(user, beFailed: false)
138-
mintFlow(to: user, amount: 1_000.0)
140+
let mintRes = mintFlow(to: user, amount: 1_000.0)
141+
Test.expect(mintRes, Test.beSucceeded())
139142
grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user)
140143

141144
// Open position: user deposits 1000 FLOW, receives ~615 MOET in their vault (topUpSource).
@@ -168,11 +171,7 @@ fun testRebalanceUndercollateralised_InsufficientTopUpSource() {
168171
message: "Position should be liquidatable after price crash")
169172

170173
// Rebalance must panic: depositing 5 MOET cannot rescue the position.
171-
let rebalanceRes = _executeTransaction(
172-
"../transactions/flow-alp/pool-management/rebalance_position.cdc",
173-
[ 0 as UInt64, true ],
174-
PROTOCOL_ACCOUNT
175-
)
174+
let rebalanceRes = rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: 0, force: true)
176175
Test.expect(rebalanceRes, Test.beFailed())
177176
Test.assertError(rebalanceRes, errorMessage: "topUpSource insufficient to save position from liquidation")
178177
}

0 commit comments

Comments
 (0)