Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 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
273 changes: 266 additions & 7 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,14 +3008,24 @@ access(all) contract FlowALPv0 {
amount: uintAmount,
tokenState: tokenState
)

// Check if we're withdrawing a debt token (debit balance)
let isDebtToken = position.balances[type]!.direction == BalanceDirection.Debit

// 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.
let postHealth = self.positionHealth(pid: pid)
assert(
postHealth >= 1.0,
message: "Post-withdrawal position health (\(postHealth)) is unhealthy"
)
//
// IMPORTANT: Skip health check if withdrawing debt token with pullFromTopUpSource=true
// In this case, we're getting funds for external use (e.g., closePosition repayment),
// and temporarily increasing debt is expected as part of the close flow.
// The debt will be immediately repaid by the calling context (closePosition).
if !isDebtToken || !pullFromTopUpSource {
let postHealth = self.positionHealth(pid: pid)
assert(
postHealth >= 1.0,
message: "Post-withdrawal position health (\(postHealth)) is unhealthy"
)
}

// Ensure that the remaining balance meets the minimum requirement (or is zero)
// Building the position view does require copying the balances, so it's less efficient than accessing the balance directly.
Expand Down Expand Up @@ -3034,6 +3056,218 @@ 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)
let positionDetails = self.getPositionDetails(pid: pid)
var totalDebtAmount: UFix64 = 0.0
var debtType: Type? = nil

for balance in positionDetails.balances {
if balance.direction == BalanceDirection.Debit {
// Accumulate debt (assuming single debt type for now)
totalDebtAmount = totalDebtAmount + UFix64(balance.balance)
debtType = balance.vaultType
}
}

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 - destroy repayment vault and just withdraw all collateral
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: Accept repayment vault (allow overshoot - extra funds help ensure full repayment)
// Users can provide more than needed to handle rounding/slippage/circular dependencies
// Note: We don't enforce minimum here - we'll check final debt after deposit instead

// Step 4: Deposit repayment funds to eliminate debt (under lock)
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
let collateralNeededForDebt = UFix64(totalEffectiveDebt / (UFix128(collateralPrice) * UFix128(collateralFactor)))

// 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
// If there's remaining debt, we need to leave enough collateral to keep position healthy

// Unlock before withdrawal (withdrawAndPull will lock again)
self._unlockPosition(pid)

// Determine withdrawal amount based on remaining debt
var collateral: @{FungibleToken.Vault}? <- nil

if totalEffectiveDebt == 0.0 {
// No remaining debt - withdraw all collateral
let fullBalance = UFix64(positionView.trueBalance(ofToken: collateralType))
collateral <-! self.withdrawAndPull(
pid: pid,
type: collateralType,
amount: fullBalance,
pullFromTopUpSource: false
)
} 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)
let borrowFactor = self.borrowFactor[debtType ?? repaymentType] ?? 1.0

let minCollateralValue = UFix64(targetHealth) * UFix64(totalEffectiveDebt) / (collateralFactor * borrowFactor)
let minCollateralAmount = minCollateralValue / collateralPrice

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

// Withdraw total minus minimum (with small buffer for safety)
let safeWithdrawAmount = totalCollateral > minCollateralAmount + 1.0
? totalCollateral - minCollateralAmount - 1.0
: 0.0

if safeWithdrawAmount > 0.0 {
collateral <-! self.withdrawAndPull(pid: pid, type: collateralType, amount: safeWithdrawAmount, pullFromTopUpSource: false)
} 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
)

return <-finalCollateral
}

///////////////////////
// POOL MANAGEMENT
///////////////////////
Expand Down Expand Up @@ -3965,6 +4199,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 @@ -4210,7 +4467,9 @@ access(all) contract FlowALPv0 {
/// A DeFiActions connector enabling withdrawals from a Position from within a DeFiActions stack.
/// This Source is intended to be constructed from a Position object.
///
access(all) struct PositionSource: DeFiActions.Source {
/// A wrapper struct that holds a reference to a Source
/// This allows passing references as Source values to closePosition()
access(all) struct PositionSource: DeFiActions.Source {

/// An optional DeFiActions.UniqueIdentifier that identifies this Sink with the DeFiActions stack its a part of
access(contract) var uniqueID: DeFiActions.UniqueIdentifier?
Expand Down
Loading