diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index d6bb8022..6368c249 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -430,7 +430,7 @@ access(all) contract FlowALPv0 { withdrawBal: withdrawBal, targetHealth: view.minHealth ) - return FlowALPMath.toUFix64Round(uintMax) + return FlowALPMath.toUFix64RoundDown(uintMax) } /// Returns the health of the given position, which is the ratio of the position's effective collateral diff --git a/cadence/tests/available_balance_rounding_test.cdc b/cadence/tests/available_balance_rounding_test.cdc new file mode 100644 index 00000000..5e335bcd --- /dev/null +++ b/cadence/tests/available_balance_rounding_test.cdc @@ -0,0 +1,128 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowALPModels" +import "test_helpers.cdc" + +// Tests that availableBalance rounds DOWN when converting from UFix128 to UFix64. +// Rounding up could return a value that, if withdrawn/borrowed, would violate the position's minimum health factor. +// +// Token setup: +// FLOW: collateralFactor=0.8, borrowFactor=1.0, price=1.0 +// MOET: collateralFactor=1.0, borrowFactor=1.0, price=1.0 (default token) +// +// Scenario: +// Deposit 100 FLOW (no debt). Available MOET borrow = 80 / 1.1 = 72.72727272727... +// UFix64 round-down: 72.72727272 +// UFix64 round-half-up: 72.72727273 (incorrect — would breach minHealth) + +access(all) let user = Test.createAccount() +access(all) let lp = Test.createAccount() + +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 + ) + + // Setup LP with MOET liquidity so the user can borrow + setupMoetVault(lp, beFailed: false) + mintMoet(signer: PROTOCOL_ACCOUNT, to: lp.address, amount: 10_000.0, beFailed: false) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, lp) + createPosition(admin: PROTOCOL_ACCOUNT, signer: lp, amount: 10_000.0, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false) + + // Setup user with FLOW + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun beforeEach() { + if getCurrentBlockHeight() > snapshot { + Test.reset(to: snapshot) + } +} + +// --------------------------------------------------------------------------- +// availableBalance should round down, not up, so the returned amount is always +// safe to withdraw without breaching minHealth. +// +// 100 FLOW deposited, no debt: +// effectiveCollateral = 100 * 1.0 * 0.8 = 80 +// maxWithdraw(MOET) = 80 / minHealth(1.1) = 72.727272727272... (UFix128) +// +// Round-down to UFix64: 72.72727272 +// Round-half-up to UFix64: 72.72727273 +// +// The test asserts the round-down value, which fails with toUFix64Round and +// passes with toUFix64RoundDown. +// --------------------------------------------------------------------------- +access(all) +fun test_availableBalance_rounds_down() { + // Open position: deposit 100 FLOW, no auto-borrow + createPosition(admin: PROTOCOL_ACCOUNT, signer: user, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + let pid = getLastPositionId() + + // availableBalance for MOET (no MOET credit, so this is the pure borrow path) + let available = getAvailableBalance( + pid: pid, + vaultIdentifier: MOET_TOKEN_IDENTIFIER, + pullFromTopUpSource: false, + beFailed: false + ) + + // 80 / 1.1 = 72.72727272727... → round-down = 72.72727272 + let expectedRoundDown: UFix64 = 72.72727272 + Test.assert(available == expectedRoundDown, + message: "availableBalance should round down: expected \(expectedRoundDown), got \(available)") +} + +// --------------------------------------------------------------------------- +// Verify the safety property: borrowing the full availableBalance amount must +// succeed without breaching minHealth. +// --------------------------------------------------------------------------- +access(all) +fun test_borrowing_full_availableBalance_succeeds() { + createPosition(admin: PROTOCOL_ACCOUNT, signer: user, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + let pid = getLastPositionId() + + let available = getAvailableBalance( + pid: pid, + vaultIdentifier: MOET_TOKEN_IDENTIFIER, + pullFromTopUpSource: false, + beFailed: false + ) + + // Borrow the exact amount returned by availableBalance + borrowFromPosition( + signer: user, + positionId: pid, + tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, + vaultStoragePath: MOET.VaultStoragePath, + amount: available, + beFailed: false + ) + + // Health should be >= minHealth (1.1) + let health = getPositionHealth(pid: pid, beFailed: false) + Test.assert(health >= 1.1, + message: "Health after borrowing full availableBalance should be >= 1.1, got \(health)") +} + diff --git a/cadence/tests/fork_multi_collateral_position_test.cdc b/cadence/tests/fork_multi_collateral_position_test.cdc index 4932c2af..1f863361 100644 --- a/cadence/tests/fork_multi_collateral_position_test.cdc +++ b/cadence/tests/fork_multi_collateral_position_test.cdc @@ -163,8 +163,8 @@ fun test_multi_collateral_position() { // STEP 6: Test weighted collateral factors - calculate max borrowing // Max borrow = (effectiveCollateral / minHealth) * borrowFactor / price - // MOET: maxBorrow = ($1325 / 1.1) * 1.0 / $1.00 = 1204.54545455 MOET - let expectedMaxMoet: UFix64 = 1204.54545455 + // MOET: maxBorrow = ($1325 / 1.1) * 1.0 / $1.00 = 1204.54545454 MOET (rounded down) + let expectedMaxMoet: UFix64 = 1204.54545454 let availableMoet = getAvailableBalance(pid: pid, vaultIdentifier: MAINNET_MOET_TOKEN_ID, pullFromTopUpSource: false, beFailed: false) Test.assertEqual(expectedMaxMoet, availableMoet)