From 2bbc4571c54b93080238cc10dcafebdd9ac01f9a Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:03:17 -0500 Subject: [PATCH 1/8] position lock --- cadence/contracts/FlowCreditMarket.cdc | 116 ++++++++++++++++++------- 1 file changed, 85 insertions(+), 31 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 80c5384..024c4cd 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -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: @@ -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 = self.positionLock.insert(key: pid, true) + } + + access(self) fun _unlockPosition(_ pid: UInt64) { + // Always unlock (even if missing) + self.positionLock = self.positionLock.remove(key: pid) + } + access(self) fun _assertLiquidationsActive() { pre { !self.liquidationsPaused: @@ -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, @@ -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 @@ -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) @@ -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) @@ -2655,24 +2682,11 @@ 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))") - } - + // NOTE: caller must have already validated pid + token support if from.balance == 0.0 { Burner.burn(<-from) return @@ -2682,8 +2696,6 @@ access(all) contract FlowCreditMarket { let type = from.getType() 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 @@ -2752,12 +2764,45 @@ access(all) contract FlowCreditMarket { // Add the money to the reserves reserveVault.deposit(from: <-from) + self._queuePositionForUpdateIfNecessary(pid: pid) + } + + /// 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))") + } + let amount = from.balance + if amount == 0.0 { + Burner.burn(<-from) + return + } + + self._lockPosition(pid) + + let amount = from.balance + let depositedUUID = from.uuid + let type = from.getType() + + self._depositEffectsOnly(pid: pid, 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, @@ -2765,6 +2810,8 @@ access(all) contract FlowCreditMarket { amount: amount, depositedUUID: depositedUUID ) + + self._unlockPosition(pid) } /// Withdraws the requested funds from the specified position. @@ -2799,10 +2846,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) } @@ -2850,19 +2899,17 @@ access(all) contract FlowCreditMarket { // 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 } @@ -2881,7 +2928,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") } @@ -2926,6 +2973,7 @@ access(all) contract FlowCreditMarket { withdrawnUUID: withdrawn.uuid ) + self._unlockPosition(pid) return <- withdrawn } @@ -2933,16 +2981,20 @@ access(all) contract FlowCreditMarket { /// 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) ---- @@ -3225,6 +3277,7 @@ 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) if self.debugLogging { log(" [CONTRACT] rebalancePosition(pid: \(pid), force: \(force))") } @@ -3232,6 +3285,7 @@ access(all) contract FlowCreditMarket { let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) if !force && (position.minHealth <= balanceSheet.health && balanceSheet.health <= position.maxHealth) { + self._unlockPosition(pid) // We aren't forcing the update, and the position is already between its desired min and max. Nothing to do! return } @@ -3260,10 +3314,9 @@ access(all) contract FlowCreditMarket { fromUnder: true ) - self.depositAndPush( + self._depositEffectsOnly( pid: pid, from: <-pulledVault, - pushToDrawDownSink: false ) } } else if balanceSheet.health > position.targetHealth { @@ -3312,10 +3365,9 @@ 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) @@ -3323,6 +3375,8 @@ access(all) contract FlowCreditMarket { } } } + + self._unlockPosition(pid) } /// Executes asynchronous updates on positions that have been queued up to the lesser of the queue length or From 4bc93457fed190c90f07f2d922946f1c17f26522 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:29:15 -0500 Subject: [PATCH 2/8] fixes --- cadence/contracts/FlowCreditMarket.cdc | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 024c4cd..5fbe984 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1224,13 +1224,15 @@ access(all) contract FlowCreditMarket { 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 = self.positionLock.insert(key: pid, true) + assert(locked == false, message: "Reentrancy: position \(pid) is locked") + self.positionLock[pid] = true + //self.positionLock = self.positionLock.insert(key: pid, true) } access(self) fun _unlockPosition(_ pid: UInt64) { // Always unlock (even if missing) - self.positionLock = self.positionLock.remove(key: pid) + //self.positionLock = self.positionLock.remove(key: pid) + self.positionLock[pid] = false } access(self) fun _assertLiquidationsActive() { @@ -2792,7 +2794,6 @@ access(all) contract FlowCreditMarket { self._lockPosition(pid) - let amount = from.balance let depositedUUID = from.uuid let type = from.getType() From 68cb959f3450d56afd9b10430e37c4c6de2ab542 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:16:01 -0500 Subject: [PATCH 3/8] check topup source type --- cadence/contracts/FlowCreditMarket.cdc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 5fbe984..3e1100a 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -2893,8 +2893,10 @@ 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. @@ -3305,7 +3307,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, From c941b82910ee1d5a1d740f2a0f041df8a74a7823 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:25:11 -0500 Subject: [PATCH 4/8] manadory health check --- cadence/contracts/FlowCreditMarket.cdc | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 3e1100a..bc183ed 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -2952,16 +2952,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) From 8917d16e5f26464cc2eef009cbc7dfd55b2b1efe Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:46:41 -0500 Subject: [PATCH 5/8] internal rebalance no lock --- cadence/contracts/FlowCreditMarket.cdc | 29 +++++++++++++++----------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index bc183ed..9bb369a 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -2767,6 +2767,15 @@ access(all) contract FlowCreditMarket { reserveVault.deposit(from: <-from) 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. @@ -2801,17 +2810,9 @@ access(all) contract FlowCreditMarket { // Rebalancing and queue management if pushToDrawDownSink { - self.rebalancePosition(pid: pid, force: true) + self._rebalancePositionNoLock(pid: pid, force: true) } - emit Deposited( - pid: pid, - poolUUID: self.uuid, - vaultType: type, - amount: amount, - depositedUUID: depositedUUID - ) - self._unlockPosition(pid) } @@ -3279,6 +3280,10 @@ access(all) contract FlowCreditMarket { /// 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))") } @@ -3286,7 +3291,6 @@ access(all) contract FlowCreditMarket { let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) if !force && (position.minHealth <= balanceSheet.health && balanceSheet.health <= position.maxHealth) { - self._unlockPosition(pid) // We aren't forcing the update, and the position is already between its desired min and max. Nothing to do! return } @@ -3379,7 +3383,6 @@ access(all) contract FlowCreditMarket { } } - self._unlockPosition(pid) } /// Executes asynchronous updates on positions that have been queued up to the lesser of the queue length or @@ -3399,6 +3402,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 @@ -3432,7 +3436,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) } //////////////// From 358a8a13359498d2a5a920860cabd5a5e05a975c Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:47:04 -0500 Subject: [PATCH 6/8] fix dup code --- cadence/contracts/FlowCreditMarket.cdc | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 9bb369a..d4a5ba0 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -2689,7 +2689,8 @@ access(all) contract FlowCreditMarket { from: @{FungibleToken.Vault} ) { // NOTE: caller must have already validated pid + token support - if from.balance == 0.0 { + let amount = from.balance + if amount == 0.0 { Burner.burn(<-from) return } @@ -2795,11 +2796,6 @@ access(all) contract FlowCreditMarket { if self.debugLogging { log(" [CONTRACT] depositAndPush(pid: \(pid), pushToDrawDownSink: \(pushToDrawDownSink))") } - let amount = from.balance - if amount == 0.0 { - Burner.burn(<-from) - return - } self._lockPosition(pid) From 7bf8601e652f9e143ebe79e382343f4e744384e1 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:09:34 -0500 Subject: [PATCH 7/8] fix missing vars --- cadence/contracts/FlowCreditMarket.cdc | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index d4a5ba0..b41e3b5 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -2697,6 +2697,7 @@ access(all) contract FlowCreditMarket { // 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) @@ -2799,9 +2800,6 @@ access(all) contract FlowCreditMarket { self._lockPosition(pid) - let depositedUUID = from.uuid - let type = from.getType() - self._depositEffectsOnly(pid: pid, from: <-from) // Rebalancing and queue management From 02d1c5041ed19f9219ce06d17e1210540aaa68ff Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:11:20 -0500 Subject: [PATCH 8/8] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bastian Müller --- cadence/contracts/FlowCreditMarket.cdc | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index b41e3b5..1495bdd 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1177,7 +1177,7 @@ access(all) contract FlowCreditMarket { access(self) var dexMaxRouteHops: UInt64 // Reentrancy guards keyed by position id - access(self) var positionLock: {UInt64: Bool?} + access(self) var positionLock: {UInt64: Bool} init(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}) { pre { @@ -1224,15 +1224,13 @@ access(all) contract FlowCreditMarket { access(self) fun _lockPosition(_ pid: UInt64) { // If key absent => unlocked let locked = self.positionLock[pid] ?? false - assert(locked == false, message: "Reentrancy: position \(pid) is locked") + assert(!locked, message: "Reentrancy: position \(pid) is locked") self.positionLock[pid] = true - //self.positionLock = self.positionLock.insert(key: pid, true) } access(self) fun _unlockPosition(_ pid: UInt64) { // Always unlock (even if missing) - //self.positionLock = self.positionLock.remove(key: pid) - self.positionLock[pid] = false + self.positionLock.remove(key: pid) } access(self) fun _assertLiquidationsActive() {