Skip to content

Commit 5f365e1

Browse files
committed
Add test proving TokenState total credit/debit accounting inconsistency
totalCreditBalance/totalDebitBalance are updated with raw "true" amounts at deposit/withdrawal time but never compounded with interest indices. As interest accrues, individual position balances grow via scaledBalance × interestIndex, but the totals remain stale sums of point-in-time values. Test 1 proves: after 1 year at 10% rate, a 100 FLOW position grows to ~110 but totalCreditBalance stays at 100. After withdrawing 100, the position retains ~10 FLOW of accrued interest while totalCreditBalance shows 0. Test 2 proves: with multiple positions depositing at different times, the gap compounds - sum of true balances (~210) exceeds totalCreditBalance (200) by the untracked interest. Also adds getTotalCreditBalance/getTotalDebitBalance public getters on Pool, a query script, and a test helper to enable observability. https://claude.ai/code/session_01Uad8KoZo93XPhndt8Aj14B
1 parent 1d28178 commit 5f365e1

4 files changed

Lines changed: 365 additions & 0 deletions

File tree

cadence/contracts/FlowALPv0.cdc

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,24 @@ access(all) contract FlowALPv0 {
327327
return self.state.getInsuranceFundBalance()
328328
}
329329

330+
/// Returns the total credit balance for a given token type.
331+
/// Returns nil if the token type is not supported.
332+
access(all) view fun getTotalCreditBalance(tokenType: Type): UFix128? {
333+
if let tokenState = self.state.getTokenState(tokenType) {
334+
return tokenState.getTotalCreditBalance()
335+
}
336+
return nil
337+
}
338+
339+
/// Returns the total debit balance for a given token type.
340+
/// Returns nil if the token type is not supported.
341+
access(all) view fun getTotalDebitBalance(tokenType: Type): UFix128? {
342+
if let tokenState = self.state.getTokenState(tokenType) {
343+
return tokenState.getTotalDebitBalance()
344+
}
345+
return nil
346+
}
347+
330348
/// Returns the insurance rate for a given token type
331349
access(all) view fun getInsuranceRate(tokenType: Type): UFix64? {
332350
if let tokenState = self.state.getTokenState(tokenType) {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import "FlowALPv0"
2+
3+
/// Returns the Pool's total credit balance for a given Vault type
4+
///
5+
/// @param vaultIdentifier: The Type identifier (e.g. vault.getType().identifier) of the related token vault
6+
///
7+
access(all)
8+
fun main(vaultIdentifier: String): UFix128 {
9+
let vaultType = CompositeType(vaultIdentifier) ?? panic("Invalid vaultIdentifier \(vaultIdentifier)")
10+
11+
let protocolAddress = Type<@FlowALPv0.Pool>().address!
12+
13+
let pool = getAccount(protocolAddress).capabilities.borrow<&FlowALPv0.Pool>(FlowALPv0.PoolPublicPath)
14+
?? panic("Could not find a configured FlowALPv0 Pool in account \(protocolAddress) at path \(FlowALPv0.PoolPublicPath)")
15+
16+
return pool.getTotalCreditBalance(tokenType: vaultType) ?? panic("Token type not supported: \(vaultIdentifier)")
17+
}

cadence/tests/test_helpers.cdc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,13 @@ fun getIsLiquidatable(pid: UInt64): Bool {
458458
return res.returnValue as! Bool
459459
}
460460

461+
access(all)
462+
fun getTotalCreditBalance(vaultIdentifier: String): UFix128 {
463+
let res = _executeScript("../scripts/flow-alp/get_total_credit_balance.cdc", [vaultIdentifier])
464+
Test.expect(res, Test.beSucceeded())
465+
return res.returnValue as! UFix128
466+
}
467+
461468
/* --- Transaction Helpers --- */
462469

463470
access(all)
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import Test
2+
import BlockchainHelpers
3+
4+
import "MOET"
5+
import "FlowToken"
6+
import "FlowALPv0"
7+
import "FlowALPModels"
8+
import "FlowALPMath"
9+
import "test_helpers.cdc"
10+
11+
// =============================================================================
12+
// TokenState Total Credit/Debit Accounting Consistency Tests
13+
// =============================================================================
14+
// These tests verify whether TokenState's totalCreditBalance and
15+
// totalDebitBalance remain consistent with the sum of individual position
16+
// balances as interest accrues over time.
17+
//
18+
// The hypothesis: totalCreditBalance/totalDebitBalance are updated with
19+
// instantaneous "true" amounts at deposit/withdrawal time, but are never
20+
// compounded with interest. As interest accrues, the sum of true position
21+
// balances diverges from totalCreditBalance, because each position's true
22+
// balance grows via scaledBalance × interestIndex, while totalCreditBalance
23+
// remains a stale sum of point-in-time additions/subtractions.
24+
// =============================================================================
25+
26+
access(all) var snapshot: UInt64 = 0
27+
28+
access(all)
29+
fun setup() {
30+
deployContracts()
31+
snapshot = getCurrentBlockHeight()
32+
}
33+
34+
// =============================================================================
35+
// Test: Single position credit balance diverges from totalCreditBalance
36+
// =============================================================================
37+
// Scenario:
38+
// 1. Create a single position with 100 FLOW credit
39+
// 2. Set FLOW interest rate to 10% APY (FixedCurve)
40+
// 3. Advance time by 1 year
41+
// 4. Position's true balance should be ~110 FLOW (100 * 1.10)
42+
// 5. Withdraw 100 FLOW (the original deposit amount)
43+
// 6. Position's remaining true balance should be ~10 FLOW (accrued interest)
44+
// 7. But totalCreditBalance = 100 (initial) - 100 (withdrawn) = 0
45+
// 8. This proves the inconsistency: position has ~10 FLOW but total says 0
46+
// =============================================================================
47+
access(all)
48+
fun test_totalCreditBalance_diverges_after_interest_accrual() {
49+
// -------------------------------------------------------------------------
50+
// STEP 1: Initialize Protocol Environment
51+
// -------------------------------------------------------------------------
52+
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0)
53+
createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false)
54+
55+
// Add FLOW with a zero-rate curve initially (we'll set the rate after deposit)
56+
addSupportedTokenZeroRateCurve(
57+
signer: PROTOCOL_ACCOUNT,
58+
tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER,
59+
collateralFactor: 0.8,
60+
borrowFactor: 1.0,
61+
depositRate: 1_000_000.0,
62+
depositCapacityCap: 1_000_000.0
63+
)
64+
65+
// -------------------------------------------------------------------------
66+
// STEP 2: Create a single position with 100 FLOW
67+
// -------------------------------------------------------------------------
68+
let lender = Test.createAccount()
69+
setupMoetVault(lender, beFailed: false)
70+
mintFlow(to: lender, amount: 100.0)
71+
72+
// Create position with 100 FLOW (no auto-borrow)
73+
createPosition(
74+
admin: PROTOCOL_ACCOUNT,
75+
signer: lender,
76+
amount: 100.0,
77+
vaultStoragePath: FLOW_VAULT_STORAGE_PATH,
78+
pushToDrawDownSink: false
79+
)
80+
let pid: UInt64 = getLastPositionId()
81+
log("Created position ".concat(pid.toString()).concat(" with 100 FLOW"))
82+
83+
// -------------------------------------------------------------------------
84+
// STEP 3: Verify initial state - totalCreditBalance should equal position balance
85+
// -------------------------------------------------------------------------
86+
let totalCreditBefore = getTotalCreditBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER)
87+
let detailsBefore = getPositionDetails(pid: pid, beFailed: false)
88+
let posBalanceBefore = getCreditBalanceForType(details: detailsBefore, vaultType: Type<@FlowToken.Vault>())
89+
90+
log("Initial totalCreditBalance: ".concat(totalCreditBefore.toString()))
91+
log("Initial position balance: ".concat(posBalanceBefore.toString()))
92+
93+
// At time 0, both should be ~100 FLOW (consistent)
94+
Test.assert(
95+
ufix128EqualWithinVariance(100.0, totalCreditBefore),
96+
message: "Initial totalCreditBalance should be ~100, got ".concat(totalCreditBefore.toString())
97+
)
98+
Test.assert(
99+
ufixEqualWithinVariance(100.0, posBalanceBefore),
100+
message: "Initial position balance should be ~100, got ".concat(posBalanceBefore.toString())
101+
)
102+
103+
// -------------------------------------------------------------------------
104+
// STEP 4: Set FLOW interest rate to 10% APY (FixedCurve)
105+
// -------------------------------------------------------------------------
106+
// With FixedCurve, the debit rate is 10%. The credit rate is:
107+
// creditRate = debitRate * (1 - protocolFeeRate)
108+
// protocolFeeRate = insuranceRate + stabilityFeeRate = 0.0 + 0.05 = 0.05
109+
// creditRate = 0.10 * (1 - 0.05) = 0.095 = 9.5% APY
110+
setInterestCurveFixed(
111+
signer: PROTOCOL_ACCOUNT,
112+
tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER,
113+
yearlyRate: 0.10
114+
)
115+
log("Set FLOW interest rate to 10% APY")
116+
117+
// -------------------------------------------------------------------------
118+
// STEP 5: Advance time by 1 year
119+
// -------------------------------------------------------------------------
120+
let timestampBefore = getBlockTimestamp()
121+
Test.moveTime(by: ONE_YEAR)
122+
Test.commitBlock()
123+
let timestampAfter = getBlockTimestamp()
124+
let timeDelta = timestampAfter - timestampBefore
125+
log("Advanced time by ".concat(timeDelta.toString()).concat(" seconds (~1 year)"))
126+
127+
// -------------------------------------------------------------------------
128+
// STEP 6: Verify position balance has grown due to interest
129+
// -------------------------------------------------------------------------
130+
let detailsAfterYear = getPositionDetails(pid: pid, beFailed: false)
131+
let posBalanceAfterYear = getCreditBalanceForType(details: detailsAfterYear, vaultType: Type<@FlowToken.Vault>())
132+
log("Position balance after 1 year: ".concat(posBalanceAfterYear.toString()))
133+
134+
// The position balance should be approximately 100 * (1 + 0.095) ≈ 109.5
135+
// (9.5% credit rate due to stability fee deduction)
136+
Test.assert(
137+
posBalanceAfterYear > 109.0 && posBalanceAfterYear < 110.5,
138+
message: "Position balance after 1 year should be ~109.5 (100 + 9.5% interest), got ".concat(posBalanceAfterYear.toString())
139+
)
140+
141+
// Check what totalCreditBalance says - it should ALSO be ~109.5 if accounting is correct
142+
let totalCreditAfterYear = getTotalCreditBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER)
143+
log("totalCreditBalance after 1 year: ".concat(totalCreditAfterYear.toString()))
144+
145+
// BUG EVIDENCE: totalCreditBalance is still 100, not ~109.5
146+
// This is because it was never compounded with interest
147+
Test.assert(
148+
ufix128EqualWithinVariance(100.0, totalCreditAfterYear),
149+
message: "totalCreditBalance after 1 year is STILL 100 (not compounded). Got: ".concat(totalCreditAfterYear.toString())
150+
)
151+
152+
// -------------------------------------------------------------------------
153+
// STEP 7: Withdraw the original 100 FLOW
154+
// -------------------------------------------------------------------------
155+
withdrawFromPosition(
156+
signer: lender,
157+
positionId: pid,
158+
tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER,
159+
amount: 100.0,
160+
pullFromTopUpSource: false
161+
)
162+
log("Withdrew 100 FLOW from position")
163+
164+
// -------------------------------------------------------------------------
165+
// STEP 8: Prove the inconsistency
166+
// -------------------------------------------------------------------------
167+
let detailsAfterWithdraw = getPositionDetails(pid: pid, beFailed: false)
168+
let posBalanceAfterWithdraw = getCreditBalanceForType(details: detailsAfterWithdraw, vaultType: Type<@FlowToken.Vault>())
169+
let totalCreditAfterWithdraw = getTotalCreditBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER)
170+
171+
log("Position balance after withdraw: ".concat(posBalanceAfterWithdraw.toString()))
172+
log("totalCreditBalance after withdraw: ".concat(totalCreditAfterWithdraw.toString()))
173+
174+
// The position still has ~9.5 FLOW remaining (accrued interest)
175+
Test.assert(
176+
posBalanceAfterWithdraw > 9.0 && posBalanceAfterWithdraw < 11.0,
177+
message: "Position should have ~9.5 FLOW remaining (accrued interest), got ".concat(posBalanceAfterWithdraw.toString())
178+
)
179+
180+
// BUG: totalCreditBalance = 100 - 100 = 0, but should be ~9.5
181+
// This is the core inconsistency: the position has real FLOW credit,
182+
// but totalCreditBalance says 0
183+
Test.assert(
184+
ufix128EqualWithinVariance(0.0, totalCreditAfterWithdraw),
185+
message: "totalCreditBalance should be 0 (100 initial - 100 withdrawn, never compounded). Got: ".concat(totalCreditAfterWithdraw.toString())
186+
)
187+
188+
// This is the proof: position has ~9.5 FLOW but totalCreditBalance = 0
189+
// The gap is exactly the amount of untracked accrued interest
190+
Test.assert(
191+
posBalanceAfterWithdraw > 9.0,
192+
message: "Position has real FLOW credit that totalCreditBalance doesn't account for"
193+
)
194+
195+
log("=== BUG CONFIRMED ===")
196+
log("Position true credit balance: ".concat(posBalanceAfterWithdraw.toString()))
197+
log("totalCreditBalance on TokenState: ".concat(totalCreditAfterWithdraw.toString()))
198+
log("The gap of ~".concat(posBalanceAfterWithdraw.toString()).concat(" FLOW is untracked accrued interest"))
199+
}
200+
201+
// =============================================================================
202+
// Test: Multiple positions amplify the divergence
203+
// =============================================================================
204+
// This test shows the problem compounds with multiple positions and time:
205+
// - Position A deposits 100 FLOW at time 0
206+
// - Time passes (interest accrues but totalCreditBalance is not updated)
207+
// - Position B deposits 100 FLOW at time 1
208+
// - The totalCreditBalance (200) understates reality (~210)
209+
// =============================================================================
210+
access(all)
211+
fun test_totalCreditBalance_understates_with_multiple_positions() {
212+
Test.reset(to: snapshot)
213+
214+
// -------------------------------------------------------------------------
215+
// Setup: Pool with FLOW at 10% rate
216+
// -------------------------------------------------------------------------
217+
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0)
218+
createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false)
219+
220+
addSupportedTokenZeroRateCurve(
221+
signer: PROTOCOL_ACCOUNT,
222+
tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER,
223+
collateralFactor: 0.8,
224+
borrowFactor: 1.0,
225+
depositRate: 1_000_000.0,
226+
depositCapacityCap: 1_000_000.0
227+
)
228+
229+
// -------------------------------------------------------------------------
230+
// Position A: Deposits 100 FLOW at time 0
231+
// -------------------------------------------------------------------------
232+
let lenderA = Test.createAccount()
233+
setupMoetVault(lenderA, beFailed: false)
234+
mintFlow(to: lenderA, amount: 100.0)
235+
236+
createPosition(
237+
admin: PROTOCOL_ACCOUNT,
238+
signer: lenderA,
239+
amount: 100.0,
240+
vaultStoragePath: FLOW_VAULT_STORAGE_PATH,
241+
pushToDrawDownSink: false
242+
)
243+
let pidA: UInt64 = getLastPositionId()
244+
245+
// Set interest rate after first deposit
246+
setInterestCurveFixed(
247+
signer: PROTOCOL_ACCOUNT,
248+
tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER,
249+
yearlyRate: 0.10
250+
)
251+
252+
// totalCreditBalance = 100 (correct at this point)
253+
let totalAfterA = getTotalCreditBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER)
254+
log("After Position A deposit: totalCreditBalance = ".concat(totalAfterA.toString()))
255+
256+
// -------------------------------------------------------------------------
257+
// Advance 1 year: Position A's true balance grows to ~109.5
258+
// -------------------------------------------------------------------------
259+
Test.moveTime(by: ONE_YEAR)
260+
Test.commitBlock()
261+
262+
let detailsA = getPositionDetails(pid: pidA, beFailed: false)
263+
let balanceA = getCreditBalanceForType(details: detailsA, vaultType: Type<@FlowToken.Vault>())
264+
log("Position A balance after 1 year: ".concat(balanceA.toString()))
265+
266+
// -------------------------------------------------------------------------
267+
// Position B: Deposits 100 FLOW at time 1
268+
// -------------------------------------------------------------------------
269+
let lenderB = Test.createAccount()
270+
setupMoetVault(lenderB, beFailed: false)
271+
mintFlow(to: lenderB, amount: 100.0)
272+
273+
createPosition(
274+
admin: PROTOCOL_ACCOUNT,
275+
signer: lenderB,
276+
amount: 100.0,
277+
vaultStoragePath: FLOW_VAULT_STORAGE_PATH,
278+
pushToDrawDownSink: false
279+
)
280+
let pidB: UInt64 = getLastPositionId()
281+
282+
// -------------------------------------------------------------------------
283+
// Compare: totalCreditBalance vs sum of true position balances
284+
// -------------------------------------------------------------------------
285+
let totalAfterB = getTotalCreditBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER)
286+
287+
let detailsAFinal = getPositionDetails(pid: pidA, beFailed: false)
288+
let balanceAFinal = getCreditBalanceForType(details: detailsAFinal, vaultType: Type<@FlowToken.Vault>())
289+
290+
let detailsBFinal = getPositionDetails(pid: pidB, beFailed: false)
291+
let balanceBFinal = getCreditBalanceForType(details: detailsBFinal, vaultType: Type<@FlowToken.Vault>())
292+
293+
let sumOfTrueBalances = balanceAFinal + balanceBFinal
294+
295+
log("totalCreditBalance: ".concat(totalAfterB.toString()))
296+
log("Position A true balance: ".concat(balanceAFinal.toString()))
297+
log("Position B true balance: ".concat(balanceBFinal.toString()))
298+
log("Sum of true balances: ".concat(sumOfTrueBalances.toString()))
299+
300+
// totalCreditBalance = 100 (from A at t=0) + 100 (from B at t=1) = 200
301+
// But sum of true balances = ~109.5 (A with interest) + 100 (B fresh) = ~209.5
302+
Test.assert(
303+
ufix128EqualWithinVariance(200.0, totalAfterB),
304+
message: "totalCreditBalance should be 200 (sum of raw deposits). Got: ".concat(totalAfterB.toString())
305+
)
306+
307+
Test.assert(
308+
sumOfTrueBalances > 209.0 && sumOfTrueBalances < 211.0,
309+
message: "Sum of true balances should be ~209.5. Got: ".concat(sumOfTrueBalances.toString())
310+
)
311+
312+
// The gap: totalCreditBalance understates reality by ~9.5 FLOW (the untracked interest)
313+
let gap = sumOfTrueBalances - UFix64(totalAfterB)
314+
Test.assert(
315+
gap > 9.0,
316+
message: "Gap between true balances and totalCreditBalance should be ~9.5 FLOW. Got: ".concat(gap.toString())
317+
)
318+
319+
log("=== BUG CONFIRMED (MULTI-POSITION) ===")
320+
log("totalCreditBalance: ".concat(totalAfterB.toString()))
321+
log("Sum of true balances: ".concat(sumOfTrueBalances.toString()))
322+
log("Gap (untracked interest): ".concat(gap.toString()))
323+
}

0 commit comments

Comments
 (0)