Skip to content
Draft
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fdc137d
relax dust balance
nialexsan Feb 13, 2026
20f9d81
Merge branch 'nialexsan/update-ref-20260211' into nialexsan/relax-rem…
nialexsan Feb 13, 2026
0a464a4
Merge branch 'main' into nialexsan/relax-remaining-balance
nialexsan Feb 13, 2026
ebe265a
restore relax
nialexsan Feb 13, 2026
8fd49d3
fix typo
nialexsan Feb 13, 2026
760b53d
update ref
nialexsan Feb 17, 2026
1833914
Merge remote-tracking branch 'origin/main' into nialexsan/relax-remai…
nialexsan Feb 19, 2026
8bcc4e2
Merge remote-tracking branch 'origin/main' into nialexsan/relax-remai…
nialexsan Feb 19, 2026
015a9d8
ref bridge exact
nialexsan Feb 19, 2026
972ac4c
update ref
nialexsan Feb 19, 2026
d9970e3
update ref
nialexsan Feb 20, 2026
3417621
Merge branch 'main' into nialexsan/relax-remaining-balance
nialexsan Feb 24, 2026
f158d12
close position method
nialexsan Feb 24, 2026
2a2e552
fix assertion
nialexsan Feb 24, 2026
8e0f6f7
close position
nialexsan Feb 25, 2026
824c388
Apply suggestion from @nialexsan
nialexsan Feb 25, 2026
47a9e67
Apply suggestion from @nialexsan
nialexsan Feb 25, 2026
94df1fc
fix lock
nialexsan Feb 25, 2026
76d6b6c
round up debt
nialexsan Feb 25, 2026
94ae8ce
repayment balance check
nialexsan Feb 25, 2026
77ab388
update deps
nialexsan Feb 25, 2026
ed16a72
revert unnecessary changes
nialexsan Feb 25, 2026
ad92e44
Apply suggestion from @nialexsan
nialexsan Feb 25, 2026
ebf1c8c
remove buffer
nialexsan Feb 25, 2026
253be6f
close position test
nialexsan Feb 25, 2026
ca37d21
add safe rounding
nialexsan Feb 26, 2026
b8366f5
Merge branch 'main' into nialexsan/relax-remaining-balance
nialexsan Feb 26, 2026
edf96dc
fix merge
nialexsan Feb 26, 2026
0b1831c
use vaults array
nialexsan Feb 28, 2026
cd7c16c
Apply suggestion from @nialexsan
nialexsan Feb 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion FlowActions
342 changes: 339 additions & 3 deletions cadence/contracts/FlowALPv0.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ access(all) contract FlowALPv0 {
withdrawnUUID: UInt64
)

/// Emitted when a position is closed via the closePosition() method.
/// This indicates a full position closure with debt repayment and collateral extraction.
access(all) event PositionClosed(
pid: UInt64,
poolUUID: UInt64,
repaymentAmount: UFix64,
repaymentType: Type,
collateralAmount: UFix64,
collateralType: Type,
finalDebt: UFix128
)

access(all) event Rebalanced(
pid: UInt64,
poolUUID: UInt64,
Expand Down Expand Up @@ -2996,9 +3008,7 @@ access(all) contract FlowALPv0 {
amount: uintAmount,
tokenState: tokenState
)
// Attempt to pull additional collateral from the top-up source (if configured)
// to keep the position above minHealth after the withdrawal.
// Regardless of whether a top-up occurs, the position must be healthy post-withdrawal.

Comment thread
nialexsan marked this conversation as resolved.
Outdated
let postHealth = self.positionHealth(pid: pid)
assert(
postHealth >= 1.0,
Expand Down Expand Up @@ -3034,6 +3044,265 @@ access(all) contract FlowALPv0 {
return <- withdrawn
}

/// Closes a position using the position's configured topUpSource for debt repayment.
/// This is a convenience method that accesses the topUpSource directly.
/// Closes a position by repaying all debt with a pre-prepared vault and returning all collateral.
///
/// This is the ONLY close method - users must prepare repayment funds externally.
/// This design eliminates circular dependencies and gives users full control over fund sourcing.
///
/// Steps:
/// 1. Calculates total debt (read-only, no lock)
/// 2. Locks the position
/// 3. Deposits repayment vault to eliminate debt
/// 4. Verifies debt is fully repaid (near-zero)
/// 5. Withdraws ALL remaining collateral (including dust)
/// 6. Returns collateral vault
///
/// @param pid: Position ID to close
/// @param repaymentVault: Vault containing funds to repay all debt (pass empty vault if no debt)
/// @param collateralType: Type of collateral to extract and return (e.g., FlowToken)
/// @return Vault containing all collateral including dust
///
access(EPosition) fun closePosition(
pid: UInt64,
repaymentVault: @{FungibleToken.Vault},
collateralType: Type
): @{FungibleToken.Vault} {
pre {
!self.isPausedOrWarmup(): "Operations are paused by governance"
self.positions[pid] != nil: "Invalid position ID"
}
post {
self.positionLock[pid] == nil: "Position is not unlocked"
}

if self.debugLogging {
log(" [CONTRACT] closePosition(pid: \(pid), collateralType: \(collateralType.identifier))")
}

// Step 1: Calculate total debt that needs to be repaid (NO LOCK NEEDED - read-only)
// Note: Debt is always MOET in this protocol
let positionDetails = self.getPositionDetails(pid: pid)
var totalDebtAmount: UFix64 = 0.0
let debtType = Type<@MOET.Vault>()

for balance in positionDetails.balances {
if balance.direction == BalanceDirection.Debit {
// Accumulate debt (balance is already UFix64, no rounding needed here)
totalDebtAmount = totalDebtAmount + balance.balance
}
}

let actualRepayment = repaymentVault.balance
let repaymentType = repaymentVault.getType()

// Step 2: Lock the position for all state modifications
self._lockPosition(pid)

// Handle no-debt case
if totalDebtAmount == 0.0 {
// No debt - assert repayment vault is empty before destroying
assert(
repaymentVault.balance == 0.0,
message: "Position has no debt but repayment vault contains \(repaymentVault.balance) \(repaymentType.identifier). ".concat(
"Either withdraw these funds or deposit them to the position separately.")
)
destroy repaymentVault

let collateralBalance = self.buildPositionView(pid: pid).trueBalance(ofToken: collateralType)
let withdrawn <- self.withdrawAndPull(
pid: pid,
type: collateralType,
amount: UFix64(collateralBalance),
pullFromTopUpSource: false
)

emit PositionClosed(
pid: pid,
poolUUID: self.uuid,
repaymentAmount: 0.0,
repaymentType: collateralType,
collateralAmount: withdrawn.balance,
collateralType: collateralType,
finalDebt: 0.0
)

self._unlockPosition(pid)
return <-withdrawn
}

// Step 3: Validate repayment vault and handle precision shortfalls
// Assert repayment vault is correct token type (MOET)
assert(
repaymentType == debtType,
message: "Repayment vault type mismatch. Expected: \(debtType.identifier), Got: \(repaymentType.identifier)"
)

assert(
repaymentVault.balance >= totalDebtAmount,
message: "Repayment should cover full debt amount provided: \(repaymentVault.balance.toString()), required: \(totalDebtAmount.toString())"
)

// Step 4: Deposit repayment funds to eliminate debt (under lock)
// Note: _depositEffectsOnly consumes the entire vault
self._depositEffectsOnly(pid: pid, from: <-repaymentVault)

// Step 5: Verify debt is acceptably low (allow tolerance for overshoot scenarios)
let updatedDetails = self.getPositionDetails(pid: pid)
var totalEffectiveDebt: UFix128 = 0.0

for balance in updatedDetails.balances {
if balance.direction == BalanceDirection.Debit {
// Calculate effective debt: (debit * price) / borrowFactor
let price = self.priceOracle.price(ofToken: balance.vaultType)
?? panic("Price not available for token \(balance.vaultType.identifier)")
let borrowFactor = self.borrowFactor[balance.vaultType]
?? panic("Borrow factor not found for token \(balance.vaultType.identifier)")

let effectiveDebt = FlowALPv0.effectiveDebt(
debit: UFix128(balance.balance),
price: UFix128(price),
borrowFactor: UFix128(borrowFactor)
)
totalEffectiveDebt = totalEffectiveDebt + effectiveDebt
}
}

// Step 6: Calculate how much collateral to return
// If there's remaining debt (e.g., from circular dependency), leave enough collateral to cover it
let positionView = self.buildPositionView(pid: pid)
let collateralBalance = positionView.trueBalance(ofToken: collateralType)

// Calculate collateral value needed to cover remaining debt
let collateralPrice = self.priceOracle.price(ofToken: collateralType)
?? panic("Price not available for collateral \(collateralType.identifier)")
let collateralFactor = self.collateralFactor[collateralType]
?? panic("Collateral factor not found for \(collateralType.identifier)")

// Remaining debt in USD / (collateral price * collateral factor) = collateral needed
// Round UP to ensure protocol keeps enough collateral to cover debt
let collateralNeededRaw = totalEffectiveDebt / (UFix128(collateralPrice) * UFix128(collateralFactor))
let collateralNeededForDebt = FlowALPMath.toUFix64RoundUp(collateralNeededRaw)

// Total available collateral in position
let totalCollateralAvailable = UFix64(collateralBalance)

// If remaining debt requires more collateral than available, that's an error
assert(
collateralNeededForDebt <= totalCollateralAvailable,
message: "Insufficient collateral to cover remaining debt. Debt requires \(collateralNeededForDebt) collateral but only \(totalCollateralAvailable) available. ".concat(
"Remaining debt: \(totalEffectiveDebt) USD. Please provide additional repayment funds.")
)

// Collateral to return = total collateral - collateral covering remaining debt
let collateralToReturn = totalCollateralAvailable - collateralNeededForDebt

// If there's no remaining debt, return all collateral
// If there is remaining debt, return reduced collateral (leaving debt coverage in position)
let withdrawableCollateral = totalEffectiveDebt > 0.0
? collateralToReturn
: totalCollateralAvailable

assert(
withdrawableCollateral > 0.0,
message: "No collateral available to return. All collateral needed to cover remaining debt: \(totalEffectiveDebt) USD"
)

// Step 7: Withdraw collateral while maintaining position health
// IMPORTANT: Keep position locked throughout withdrawal to prevent race conditions
// Do NOT unlock before withdrawal - we do direct withdrawal while holding the lock

// Determine withdrawal amount based on remaining debt
var withdrawAmount: UFix64 = 0.0

if totalEffectiveDebt == 0.0 {
// No remaining debt - withdraw all collateral
withdrawAmount = UFix64(positionView.trueBalance(ofToken: collateralType))
} else {
// Remaining debt exists - calculate safe withdrawal maintaining target health
let position = self._borrowPosition(pid: pid)
let targetHealth = position.targetHealth

// Calculate collateral needed to maintain target health:
// (collateralValue * collateralFactor) / (debtValue / borrowFactor) >= targetHealth
// collateralValue >= (targetHealth * debtValue) / (collateralFactor * borrowFactor)
// Debt is always MOET, so use MOET's borrow factor
let borrowFactor = self.borrowFactor[debtType] ?? 1.0

let minCollateralValue = UFix64(targetHealth) * UFix64(totalEffectiveDebt) / (collateralFactor * borrowFactor)
// Round UP to ensure protocol keeps enough collateral
let minCollateralAmountRaw = UFix128(minCollateralValue) / UFix128(collateralPrice)
let minCollateralAmount = FlowALPMath.toUFix64RoundUp(minCollateralAmountRaw)

// Get total collateral
let totalCollateral = UFix64(positionView.trueBalance(ofToken: collateralType))

// Withdraw total minus minimum (with small buffer for safety)
if totalCollateral > minCollateralAmount + 1.0 {
withdrawAmount = totalCollateral - minCollateralAmount - 1.0
} else {
withdrawAmount = 0.0
}
}

// Perform direct withdrawal while holding lock (no health check needed for close)
var collateral: @{FungibleToken.Vault}? <- nil

if withdrawAmount > 0.0 {
let position = self._borrowPosition(pid: pid)
let tokenState = self._borrowUpdatedTokenState(type: collateralType)
let reserveVault = (&self.reserves[collateralType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)!

// Record withdrawal in position balance
if position.balances[collateralType] == nil {
position.balances[collateralType] = InternalBalance(
direction: BalanceDirection.Credit,
scaledBalance: 0.0
)
}
position.balances[collateralType]!.recordWithdrawal(
amount: UFix128(withdrawAmount),
tokenState: tokenState
)

// Queue for update if necessary
self._queuePositionForUpdateIfNecessary(pid: pid)

// Withdraw from reserves
collateral <-! reserveVault.withdraw(amount: withdrawAmount)
} else {
collateral <-! DeFiActionsUtils.getEmptyVault(collateralType)
}

let finalCollateral <- collateral!
let finalCollateralAmount = finalCollateral.balance

// Emit event for position closure
emit PositionClosed(
pid: pid,
poolUUID: self.uuid,
repaymentAmount: actualRepayment,
repaymentType: repaymentType,
collateralAmount: finalCollateralAmount,
collateralType: collateralType,
finalDebt: totalEffectiveDebt
)

emit Withdrawn(
pid: pid,
poolUUID: self.uuid,
vaultType: collateralType,
amount: finalCollateralAmount,
withdrawnUUID: finalCollateral.uuid
)

// Unlock position now that all operations are complete
self._unlockPosition(pid)

return <-finalCollateral
}

///////////////////////
// POOL MANAGEMENT
///////////////////////
Expand Down Expand Up @@ -3852,6 +4121,34 @@ access(all) contract FlowALPv0 {
return pool.getPositionDetails(pid: self.id).balances
}

/// Returns the total debt amount and debt token type for this position.
/// This is a convenience method for strategies to avoid recalculating debt from balances.
///
/// Note: Debt is always in MOET in this protocol.
/// Returns exact debt amount - no buffer needed since measurement and repayment happen
/// in the same transaction (no interest accrual between reads).
///
/// @return DebtInfo struct with exact debt amount and tokenType (always MOET).
access(all) fun getTotalDebt(): DebtInfo {
let pool = self.pool.borrow()!
let balances = pool.getPositionDetails(pid: self.id).balances
var totalDebtAmount: UFix64 = 0.0

for balance in balances {
if balance.direction == BalanceDirection.Debit {
// Accumulate debt amount
totalDebtAmount = totalDebtAmount + balance.balance
}
}

// Debt is always MOET in this protocol
// NOTE: Strategies using this must ensure their swap sources have sufficient
// liquidity. SwapSource.minimumAvailable() may return slightly less than
// actual debt due to source liquidity constraints or precision loss in
// swap calculations. Strategies should handle this appropriately.
return DebtInfo(amount: totalDebtAmount, tokenType: Type<@MOET.Vault>())
}

/// Returns the balance available for withdrawal of a given Vault type. If pullFromTopUpSource is true, the
/// calculation will be made assuming the position is topped up if the withdrawal amount puts the Position
/// below its min health. If pullFromTopUpSource is false, the calculation will return the balance currently
Expand Down Expand Up @@ -3965,6 +4262,29 @@ access(all) contract FlowALPv0 {
)
}

/// Closes the position by repaying all debt with a pre-prepared vault and returning all collateral.
///
/// This is the ONLY close method. Users must prepare repayment funds externally.
/// This design eliminates circular dependencies and gives users full control over fund sourcing.
///
/// See Pool.closePosition() for detailed implementation documentation.
///
/// @param repaymentVault: Vault containing funds to repay all debt (pass empty vault if no debt)
/// @param collateralType: Type of collateral to extract and return (e.g., FlowToken)
/// @return Vault containing all collateral including dust
///
access(FungibleToken.Withdraw) fun closePosition(
repaymentVault: @{FungibleToken.Vault},
collateralType: Type
): @{FungibleToken.Vault} {
let pool = self.pool.borrow()!
return <- pool.closePosition(
pid: self.id,
repaymentVault: <-repaymentVault,
collateralType: collateralType
)
}

/// Returns a new Sink for the given token type that will accept deposits of that token
/// and update the position's collateral and/or debt accordingly.
///
Expand Down Expand Up @@ -4319,6 +4639,22 @@ access(all) contract FlowALPv0 {
///
/// A structure returned externally to report a position's balance for a particular token.
/// This structure is NOT used internally.
/// DebtInfo
///
/// A structure returned by getTotalDebt() to report the total debt and debt token type.
access(all) struct DebtInfo {
/// The total amount of debt
access(all) let amount: UFix64

/// The type of the debt token (nil if no debt)
access(all) let tokenType: Type?

init(amount: UFix64, tokenType: Type?) {
self.amount = amount
self.tokenType = tokenType
}
}

access(all) struct PositionBalance {

/// The token type for which the balance details relate to
Expand Down
Loading
Loading