Skip to content
Open
Changes from all commits
Commits
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
146 changes: 100 additions & 46 deletions cadence/contracts/FlowCreditMarket.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,9 @@ access(all) contract FlowCreditMarket {
/// Max route hops allowed for DEX liquidations
access(self) var dexMaxRouteHops: UInt64

// Reentrancy guards keyed by position id
access(self) var positionLock: {UInt64: Bool}

init(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}) {
pre {
priceOracle.unitOfAccount() == defaultToken:
Expand Down Expand Up @@ -1212,10 +1215,24 @@ access(all) contract FlowCreditMarket {
self.dexMaxSlippageBps = 100
self.dexMaxRouteHops = 3

self.positionLock = {}

// The pool starts with an empty reserves map.
// Vaults will be created when tokens are first deposited.
}

access(self) fun _lockPosition(_ pid: UInt64) {
// If key absent => unlocked
let locked = self.positionLock[pid] ?? false
assert(!locked, message: "Reentrancy: position \(pid) is locked")
self.positionLock[pid] = true
}

access(self) fun _unlockPosition(_ pid: UInt64) {
// Always unlock (even if missing)
self.positionLock.remove(key: pid)
}

access(self) fun _assertLiquidationsActive() {
pre {
!self.liquidationsPaused:
Expand Down Expand Up @@ -1794,6 +1811,8 @@ access(all) contract FlowCreditMarket {
// Pause/warm-up checks
self._assertLiquidationsActive()

self._lockPosition(pid)

// Quote required repay and seize
let quote = self.quoteLiquidation(
pid: pid,
Expand Down Expand Up @@ -1884,7 +1903,11 @@ access(all) contract FlowCreditMarket {
newHF: actualNewHF
)

return <- create LiquidationResult(seized: <-payout, remainder: <-from)
let liquidationResult <- create LiquidationResult(seized: <-payout, remainder: <-from)

self._unlockPosition(pid)

return <- liquidationResult
}

/// Liquidation via DEX: seize collateral, swap via allowlisted Swapper to debt token, repay debt
Expand All @@ -1907,6 +1930,8 @@ access(all) contract FlowCreditMarket {
}
self._assertLiquidationsActive()

self._lockPosition(pid)

// Ensure reserve vaults exist for both tokens
if self.reserves[seizeType] == nil {
self.reserves[seizeType] <-! DeFiActionsUtils.getEmptyVault(seizeType)
Expand Down Expand Up @@ -2028,6 +2053,8 @@ access(all) contract FlowCreditMarket {
slippageBps: slipBps,
newHF: self.positionHealth(pid: pid)
)

self._unlockPosition(pid)
}

// Internal helpers for DEX liquidation path (resource-scoped)
Expand Down Expand Up @@ -2655,35 +2682,22 @@ access(all) contract FlowCreditMarket {
)
}

/// Deposits the provided funds to the specified position with the configurable `pushToDrawDownSink` option.
/// If `pushToDrawDownSink` is true, excess value putting the position above its max health
/// is pushed to the position's configured `drawDownSink`.
access(EPosition) fun depositAndPush(
access(self) fun _depositEffectsOnly(
pid: UInt64,
from: @{FungibleToken.Vault},
pushToDrawDownSink: Bool
from: @{FungibleToken.Vault}
) {
pre {
self.positions[pid] != nil:
"Invalid position ID \(pid) - could not find an InternalPosition with the requested ID in the Pool"
self.globalLedger[from.getType()] != nil:
"Invalid token type \(from.getType().identifier) - not supported by this Pool"
}
if self.debugLogging {
log(" [CONTRACT] depositAndPush(pid: \(pid), pushToDrawDownSink: \(pushToDrawDownSink))")
}

if from.balance == 0.0 {
// NOTE: caller must have already validated pid + token support
let amount = from.balance
if amount == 0.0 {
Burner.burn(<-from)
return
}

// Get a reference to the user's position and global token state for the affected token.
let type = from.getType()
let depositedUUID = from.uuid
let position = self._borrowPosition(pid: pid)
let tokenState = self._borrowUpdatedTokenState(type: type)
let amount = from.balance
let depositedUUID = from.uuid

// Time-based state is handled by the tokenState() helper function

Expand Down Expand Up @@ -2752,19 +2766,46 @@ access(all) contract FlowCreditMarket {
// Add the money to the reserves
reserveVault.deposit(from: <-from)

// Rebalancing and queue management
if pushToDrawDownSink {
self.rebalancePosition(pid: pid, force: true)
}

self._queuePositionForUpdateIfNecessary(pid: pid)

emit Deposited(
pid: pid,
poolUUID: self.uuid,
vaultType: type,
amount: amount,
depositedUUID: depositedUUID
)

}

/// Deposits the provided funds to the specified position with the configurable `pushToDrawDownSink` option.
/// If `pushToDrawDownSink` is true, excess value putting the position above its max health
/// is pushed to the position's configured `drawDownSink`.
access(EPosition) fun depositAndPush(
pid: UInt64,
from: @{FungibleToken.Vault},
pushToDrawDownSink: Bool
) {
pre {
self.positions[pid] != nil:
"Invalid position ID \(pid) - could not find an InternalPosition with the requested ID in the Pool"
self.globalLedger[from.getType()] != nil:
"Invalid token type \(from.getType().identifier) - not supported by this Pool"
}
if self.debugLogging {
log(" [CONTRACT] depositAndPush(pid: \(pid), pushToDrawDownSink: \(pushToDrawDownSink))")
}

self._lockPosition(pid)

self._depositEffectsOnly(pid: pid, from: <-from)

// Rebalancing and queue management
if pushToDrawDownSink {
self._rebalancePositionNoLock(pid: pid, force: true)
}

self._unlockPosition(pid)
}

/// Withdraws the requested funds from the specified position.
Expand Down Expand Up @@ -2799,10 +2840,12 @@ access(all) contract FlowCreditMarket {
self.globalLedger[type] != nil:
"Invalid token type \(type.identifier) - not supported by this Pool"
}
self._lockPosition(pid)
if self.debugLogging {
log(" [CONTRACT] withdrawAndPull(pid: \(pid), type: \(type.identifier), amount: \(amount), pullFromTopUpSource: \(pullFromTopUpSource))")
}
if amount == 0.0 {
self._unlockPosition(pid)
return <- DeFiActionsUtils.getEmptyVault(type)
}

Expand Down Expand Up @@ -2843,26 +2886,26 @@ access(all) contract FlowCreditMarket {
)

let pulledVault <- topUpSource.withdrawAvailable(maxAmount: idealDeposit)
assert(pulledVault.getType() == topUpType, message: "topUpSource returned unexpected token type")
let pulledAmount = pulledVault.balance


// NOTE: We requested the "ideal" deposit, but we compare against the required deposit here.
// The top up source may not have enough funds get us to the target health, but could have
// enough to keep us over the minimum.
if pulledAmount >= requiredDeposit {
// We can service this withdrawal if we deposit funds from our top up source
self.depositAndPush(
self._depositEffectsOnly(
pid: pid,
from: <-pulledVault,
pushToDrawDownSink: false
from: <-pulledVault
)
usedTopUp = pulledAmount > 0.0
canWithdraw = true
} else {
// We can't get the funds required to service this withdrawal, so we need to redeposit what we got
self.depositAndPush(
self._depositEffectsOnly(
pid: pid,
from: <-pulledVault,
pushToDrawDownSink: false
from: <-pulledVault
)
usedTopUp = pulledAmount > 0.0
}
Expand All @@ -2881,7 +2924,7 @@ access(all) contract FlowCreditMarket {
log(" [CONTRACT] Required deposit for minHealth: \(requiredDeposit)")
log(" [CONTRACT] Pull from topUpSource: \(pullFromTopUpSource)")
}

self._unlockPosition(pid)
// We can't service this withdrawal, so we just abort
panic("Cannot withdraw \(amount) of \(type.identifier) from position ID \(pid) - Insufficient funds for withdrawal")
}
Expand All @@ -2902,16 +2945,14 @@ access(all) contract FlowCreditMarket {
amount: uintAmount,
tokenState: tokenState
)
// Ensure that this withdrawal doesn't cause the position to be overdrawn.
// Skip the assertion only when a top-up was used in this call and the immediate
// post-withdrawal health is 0 (transitional state before top-up effects fully reflect).
// 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 final post-call health must satisfy minHealth.
let postHealth = self.positionHealth(pid: pid)
if !(usedTopUp && postHealth == 0.0) {
assert(
position.minHealth <= postHealth,
message: "Position is overdrawn"
)
}
assert(
position.minHealth <= postHealth,
message: "Position is overdrawn"
)

// Queue for update if necessary
self._queuePositionForUpdateIfNecessary(pid: pid)
Expand All @@ -2926,23 +2967,28 @@ access(all) contract FlowCreditMarket {
withdrawnUUID: withdrawn.uuid
)

self._unlockPosition(pid)
return <- withdrawn
}

/// Sets the InternalPosition's drawDownSink. If `nil`, the Pool will not be able to push overflown value when
/// the position exceeds its maximum health. Note, if a non-nil value is provided, the Sink MUST accept the
/// Pool's default deposits or the operation will revert.
access(EPosition) fun provideDrawDownSink(pid: UInt64, sink: {DeFiActions.Sink}?) {
self._lockPosition(pid)
let position = self._borrowPosition(pid: pid)
position.setDrawDownSink(sink)
self._unlockPosition(pid)
}

/// Sets the InternalPosition's topUpSource.
/// If `nil`, the Pool will not be able to pull underflown value when
/// the position falls below its minimum health which may result in liquidation.
access(EPosition) fun provideTopUpSource(pid: UInt64, source: {DeFiActions.Source}?) {
self._lockPosition(pid)
let position = self._borrowPosition(pid: pid)
position.setTopUpSource(source)
self._unlockPosition(pid)
}

// ---- Position health accessors (called via Position using EPosition capability) ----
Expand Down Expand Up @@ -3225,6 +3271,11 @@ access(all) contract FlowCreditMarket {
/// If `force` is `true`, the position will be rebalanced even if it is currently healthy.
/// Otherwise, this function will do nothing if the position is within the min/max health bounds.
access(EPosition) fun rebalancePosition(pid: UInt64, force: Bool) {
self._lockPosition(pid)
self._rebalancePositionNoLock(pid: pid, force: force)
self._unlockPosition(pid)
}
access(self) fun _rebalancePositionNoLock(pid: UInt64, force: Bool) {
if self.debugLogging {
log(" [CONTRACT] rebalancePosition(pid: \(pid), force: \(force))")
}
Expand All @@ -3250,7 +3301,9 @@ access(all) contract FlowCreditMarket {
log(" [CONTRACT] idealDeposit: \(idealDeposit)")
}

let topUpType = topUpSource.getSourceType()
let pulledVault <- topUpSource.withdrawAvailable(maxAmount: idealDeposit)
assert(pulledVault.getType() == topUpType, message: "topUpSource returned unexpected token type")

emit Rebalanced(
pid: pid,
Expand All @@ -3260,10 +3313,9 @@ access(all) contract FlowCreditMarket {
fromUnder: true
)

self.depositAndPush(
self._depositEffectsOnly(
pid: pid,
from: <-pulledVault,
pushToDrawDownSink: false
)
}
} else if balanceSheet.health > position.targetHealth {
Expand Down Expand Up @@ -3312,17 +3364,17 @@ access(all) contract FlowCreditMarket {
// Push what we can into the sink, and redeposit the rest
drawDownSink.depositCapacity(from: &sinkVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
if sinkVault.balance > 0.0 {
self.depositAndPush(
self._depositEffectsOnly(
pid: pid,
from: <-sinkVault,
pushToDrawDownSink: false
)
} else {
Burner.burn(<-sinkVault)
}
}
}
}

}

/// Executes asynchronous updates on positions that have been queued up to the lesser of the queue length or
Expand All @@ -3342,6 +3394,7 @@ access(all) contract FlowCreditMarket {

/// Executes an asynchronous update on the specified position
access(EImplementation) fun asyncUpdatePosition(pid: UInt64) {
self._lockPosition(pid)
let position = self._borrowPosition(pid: pid)

// First check queued deposits, their addition could affect the rebalance we attempt later
Expand Down Expand Up @@ -3375,7 +3428,8 @@ access(all) contract FlowCreditMarket {

// Now that we've deposited a non-zero amount of any queued deposits, we can rebalance
// the position if necessary.
self.rebalancePosition(pid: pid, force: false)
self._rebalancePositionNoLock(pid: pid, force: false)
self._unlockPosition(pid)
}

////////////////
Expand Down
Loading