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 01/18] 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 80c53842..024c4cd7 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 02/18] 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 024c4cd7..5fbe984d 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 03/18] 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 5fbe984d..3e1100a4 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 04/18] 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 3e1100a4..bc183ed5 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 05/18] 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 bc183ed5..9bb369ad 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 06/18] 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 9bb369ad..d4a5ba05 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 07/18] 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 d4a5ba05..b41e3b54 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 08/18] 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 b41e3b54..1495bdd1 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() { From 37689da8c544c100d352afcd21e656e3ad7cc0a6 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:51:40 -0500 Subject: [PATCH 09/18] add post on lock method --- cadence/contracts/FlowCreditMarket.cdc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 7a558a34..b6dc0e0e 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1255,6 +1255,9 @@ access(all) contract FlowCreditMarket { } access(self) fun _unlockPosition(_ pid: UInt64) { + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } // Always unlock (even if missing) self.positionLock.remove(key: pid) } From 29b504b9712866fec9a3a65699dae6377e3052cf Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:11:49 -0500 Subject: [PATCH 10/18] fix typo --- cadence/contracts/FlowCreditMarket.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index ab24deeb..a7160953 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1917,7 +1917,7 @@ access(all) contract FlowCreditMarket { // Execute the liquidation let seizedCollateral <- self._doLiquidation(pid: pid, repayment: <-repayment, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) - self._lockPosition(pid) + self._unlockPosition(pid) return <- seizedCollateral } From 3cd0dd247ed06eebf0309a509ed54c7a0b0d84e0 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Mon, 2 Feb 2026 20:07:04 -0500 Subject: [PATCH 11/18] testing contracts and helpers --- .../mocks/MockFlowCreditMarketConsumer.cdc | 17 +- ...rsarial_recursive_withdraw_source_test.cdc | 219 ++++++++++++++++ .../tests/adversarial_type_spoofing_test.cdc | 77 ++++++ .../AdversarialReentrancyConnectors.cdc | 239 ++++++++++++++++++ .../AdversarialTypeSpoofingConnectors.cdc | 58 +++++ cadence/tests/test_helpers.cdc | 15 ++ .../withdraw_from_position.cdc | 39 +++ .../create_wrapped_position_hack.cdc | 95 +++++++ ...eate_wrapped_position_malicious_source.cdc | 75 ++++++ flow.json | 14 +- 10 files changed, 846 insertions(+), 2 deletions(-) create mode 100644 cadence/tests/adversarial_recursive_withdraw_source_test.cdc create mode 100644 cadence/tests/adversarial_type_spoofing_test.cdc create mode 100644 cadence/tests/contracts/AdversarialReentrancyConnectors.cdc create mode 100644 cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc create mode 100644 cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc create mode 100644 cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_hack.cdc create mode 100644 cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_malicious_source.cdc diff --git a/cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc b/cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc index a1ae194d..90bb16fa 100644 --- a/cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc +++ b/cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc @@ -38,16 +38,31 @@ access(all) contract MockFlowCreditMarketConsumer { let position = FlowCreditMarket.Position(id: pid, pool: poolCap) self.account.storage.save(poolCap, to: FlowCreditMarket.PoolCapStoragePath) return <- create PositionWrapper( + pid: pid, position: position ) } + access(all) + fun getPoolCapability(): + Capability { + + let poolCapCopy = self.account.storage.copy< + Capability + >(from: FlowCreditMarket.PoolCapStoragePath) + ?? panic("Missing pool capability") + + return poolCapCopy + } + /// A simple resource encapsulating a FlowCreditMarket Position access(all) resource PositionWrapper { access(self) let position: FlowCreditMarket.Position + access(all) let positionID: UInt64 - init(position: FlowCreditMarket.Position) { + init(pid: UInt64, position: FlowCreditMarket.Position) { + self.positionID = pid self.position = position } diff --git a/cadence/tests/adversarial_recursive_withdraw_source_test.cdc b/cadence/tests/adversarial_recursive_withdraw_source_test.cdc new file mode 100644 index 00000000..9c7ef0d7 --- /dev/null +++ b/cadence/tests/adversarial_recursive_withdraw_source_test.cdc @@ -0,0 +1,219 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowCreditMarket" +import "DeFiActions" +import "DeFiActionsUtils" +import "FlowToken" +import "test_helpers.cdc" +import "FungibleToken" + +access(all) let protocolAccount = Test.getAccount(0x0000000000000007) +access(all) let protocolConsumerAccount = Test.getAccount(0x0000000000000008) +access(all) let userAccount = Test.createAccount() + +access(all) let flowTokenIdentifier = "A.0000000000000003.FlowToken.Vault" +access(all) let moetTokenIdentifier = "A.0000000000000007.MOET.Vault" +access(all) let flowVaultStoragePath = /storage/flowTokenVault +access(all) let wrapperStoragePath = /storage/flowCreditMarketPositionWrapper + +access(all) let flowBorrowFactor = 1.0 +access(all) let flowStartPrice = 1.0 +access(all) let positionFundingAmount = 1_000.0 + +access(all) var snapshot: UInt64 = 0 +access(all) var positionID: UInt64 = 0 + +access(all) +fun setup() { + deployContracts() + + let betaTxResult = grantBeta(protocolAccount, protocolConsumerAccount) + Test.expect(betaTxResult, Test.beSucceeded()) + + // Price setup + setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: flowStartPrice) + setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: moetTokenIdentifier, price: 1.0) + + // Create the Pool & add FLOW as supported token + createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: moetTokenIdentifier, beFailed: false) + addSupportedTokenZeroRateCurve( + signer: protocolAccount, + tokenTypeIdentifier: flowTokenIdentifier, + collateralFactor: 0.65, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // Prep user's account + setupMoetVault(userAccount, beFailed: false) + mintFlow(to: userAccount, amount: positionFundingAmount * 2.0) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun testRecursiveWithdrawSource() { + if snapshot < getCurrentBlockHeight() { + Test.reset(to: snapshot) + } + + // Setup user 1 - Giving pool 10000 Flow to borrow + let user1 = Test.createAccount() + setupMoetVault(user1, beFailed: false) + mintMoet(signer: protocolAccount, to: user1.address, amount: 10000.0, beFailed: false) + mintFlow(to: user1, amount: 10000.0) + grantPoolCapToConsumer() + + let initialDeposit1 = 10000.0 + createWrappedPosition(signer: user1, amount: initialDeposit1, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: false) + log("[TEST] USER1 POSITION ID: \(positionID)") + + // ============================== + + // Open a position with pushToDrawDownSink=true to get some MOET borrowed + let openRes = executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position_hack.cdc", + [positionFundingAmount, flowVaultStoragePath, false], + userAccount + ) + Test.expect(openRes, Test.beSucceeded()) + + // Get position ID from events + var evts = Test.eventsOfType(Type()) + let openedEvt = evts[evts.length - 1] as! FlowCreditMarket.Opened + positionID = openedEvt.pid + log("[TEST] Position opened with ID: \(positionID)") + + let remainingFlow = getBalance(address: userAccount.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 + log("[TEST] Remaining Flow: \(remainingFlow)") + let moetBalance = getBalance(address: userAccount.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("[TEST] Remaining MOET: \(moetBalance)") + + // put 1000 Flow into the position + // somehow only 650 get put into it? + // took 500 out of the position + + let withdrawRes = executeTransaction( + "./transactions/flow-credit-market/pool-management/withdraw_from_position.cdc", + [positionID, flowTokenIdentifier, 1500.0, true], // pullFromTopUpSource: true + userAccount + ) + Test.expect(withdrawRes, Test.beFailed()) + + let currentFlow = getBalance(address: userAccount.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 + log("[TEST] Current Flow: \(currentFlow)") + let currentMoet = getBalance(address: userAccount.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("[TEST] Current MOET: \(currentMoet)") + + + + + // let withdrawRes2 = executeTransaction( + // "./transactions/flow-credit-market/pool-management/withdraw_from_position.cdc", + // [positionID, flowTokenIdentifier, 10000.0, false], // pullFromTopUpSource: true + // user1 + // ) + // Test.expect(withdrawRes2, Test.beSucceeded()) + // log("[TEST] Withdrawal 2 succeeded") + + // log info about the position + // log("[TEST] Position info: \(getPositionInfo(pid: positionID, beFailed: false))") + + // // Get initial available balance without topUpSource + // let initialAvailable = getAvailableBalance( + // pid: positionID, + // vaultIdentifier: moetTokenIdentifier, + // pullFromTopUpSource: false, + // beFailed: false + // ) + // log("[TEST] Initial available balance (no topUp): \(initialAvailable)") + + // // Calculate a withdrawal amount that will require topUpSource + // // We need to withdraw more than what's available without topUpSource + // let largeWithdrawAmount = initialAvailable * 1.00000001 + // // let largeWithdrawAmount = 110.0 + // log("[TEST] Large withdrawal amount (requires topUp): \(largeWithdrawAmount)") + + // // Calculate a smaller withdrawal amount that does NOT require topUpSource + // // This should be less than the available balance + // let smallWithdrawAmount = initialAvailable * 0.3 + // log("[TEST] Small withdrawal amount (no topUp needed): \(smallWithdrawAmount)") + + // // Verify that the large amount requires topUpSource (when topUpSource provides MOET) + // let requiredForLarge = fundsRequiredForTargetHealthAfterWithdrawing( + // pid: positionID, + // depositType: flowTokenIdentifier, + // targetHealth: UFix128(minHealth), + // withdrawType: moetTokenIdentifier, + // withdrawAmount: largeWithdrawAmount, + // beFailed: false + // ) + // log("[TEST] Required deposit for large withdrawal (with MOET topUp): \(requiredForLarge)") + // Test.assert(requiredForLarge > 0.0, message: "Large withdrawal should require topUpSource") + + // // Verify that the small amount does NOT require topUpSource + // let requiredForSmall = fundsRequiredForTargetHealthAfterWithdrawing( + // pid: positionID, + // depositType: flowTokenIdentifier, + // targetHealth: UFix128(minHealth), + // withdrawType: moetTokenIdentifier, + // withdrawAmount: smallWithdrawAmount, + // beFailed: false + // ) + // log("[TEST] Required deposit for small withdrawal: \(requiredForSmall)") + // Test.assert(requiredForSmall == 0.0, message: "Small withdrawal should NOT require topUpSource") + + // // Also check what would be required if topUpSource provides Flow (for our recursive source) + // let requiredForLargeWithFlow = fundsRequiredForTargetHealthAfterWithdrawing( + // pid: positionID, + // depositType: flowTokenIdentifier, + // targetHealth: UFix128(minHealth), + // withdrawType: moetTokenIdentifier, + // withdrawAmount: largeWithdrawAmount, + // beFailed: false + // ) + // log("[TEST] Required deposit for large withdrawal (with Flow topUp): \(requiredForLargeWithFlow)") + + // // Ensure user has enough Flow in their vault for the topUpSource + // // The user should have some Flow remaining after opening the position + // let userFlowBalance = getBalance(address: userAccount.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 + // log("[TEST] User Flow balance: \(userFlowBalance)") + + // // Ensure user has enough Flow for the topUpSource (we need Flow, not MOET, since our source provides Flow) + // // Mint additional Flow if needed + // if userFlowBalance < requiredForLargeWithFlow { + // let additionalFlow = requiredForLargeWithFlow - userFlowBalance + 100.0 // Add buffer + // mintFlow(to: userAccount, amount: additionalFlow) + // log("[TEST] Minted \(additionalFlow) Flow to user") + // } + + // // Now make a withdrawal with the large amount that requires pullFromTopUpSource + // // This should trigger the recursive source's withdrawAvailable, which will + // // call withdrawAndPull with the small amount (without pullFromTopUpSource) + // // log("[TEST] Making large withdrawal that requires topUpSource...") + + // let withdrawRes = executeTransaction( + // "./transactions/flow-credit-market/pool-management/withdraw_from_position.cdc", + // [positionID, moetTokenIdentifier, largeWithdrawAmount, true], // pullFromTopUpSource: true + // userAccount + // ) + + // // The transaction should succeed because: + // // 1. withdrawAndPull is called with largeWithdrawAmount and pullFromTopUpSource: true + // // 2. This triggers the recursive source's withdrawAvailable + // // 3. The recursive source calls withdrawAndPull with smallWithdrawAmount and pullFromTopUpSource: false + // // 4. This nested call succeeds because smallWithdrawAmount doesn't require topUpSource + + // Test.expect(withdrawRes, Test.beSucceeded()) + // log("[TEST] Large withdrawal succeeded with recursive source") + + // // Verify the position health is still above minimum + let finalHealth = getPositionHealth(pid: positionID, beFailed: false) + log("[TEST] Final position health: \(finalHealth)") + // Test.assert(finalHealth >= UFix128(minHealth), message: "Position health should be at or above minimum") + + // log("==============================") +} diff --git a/cadence/tests/adversarial_type_spoofing_test.cdc b/cadence/tests/adversarial_type_spoofing_test.cdc new file mode 100644 index 00000000..d22b3722 --- /dev/null +++ b/cadence/tests/adversarial_type_spoofing_test.cdc @@ -0,0 +1,77 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowCreditMarket" +import "DeFiActions" +import "DeFiActionsUtils" +import "MockFlowCreditMarketConsumer" +import "FlowToken" +import "test_helpers.cdc" +import "FungibleToken" + +access(all) let protocolAccount = Test.getAccount(0x0000000000000007) +access(all) let liquidityAccount = Test.getAccount(0x0000000000000009) +access(all) var hackerAccount = Test.getAccount(0x0000000000000008) + +access(all) let flowTokenIdentifier = "A.0000000000000003.FlowToken.Vault" +access(all) let moetTokenIdentifier = "A.0000000000000007.MOET.Vault" +access(all) let flowVaultStoragePath = /storage/flowTokenVault +access(all) let wrapperStoragePath = /storage/flowCreditMarketPositionWrapper + +access(all) +fun setup() { + deployContracts() + + let betaTxResult1 = grantBeta(protocolAccount, liquidityAccount) + Test.expect(betaTxResult1, Test.beSucceeded()) + let betaTxResult2 = grantBeta(protocolAccount, hackerAccount) + Test.expect(betaTxResult2, Test.beSucceeded()) + + setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: 0.0001) + setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: moetTokenIdentifier, price: 1.0) + + // Create the Pool & add FLOW as supported token + createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: moetTokenIdentifier, beFailed: false) + addSupportedTokenZeroRateCurve( + signer: protocolAccount, + tokenTypeIdentifier: flowTokenIdentifier, + collateralFactor: 0.65, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + mintFlow(to: liquidityAccount, amount: 10000.0) + mintFlow(to: hackerAccount, amount: 2.0) + setupMoetVault(hackerAccount, beFailed: false) + + // provide liquidity to the pool we can extract + createWrappedPosition(signer: liquidityAccount, amount: 10000.0, vaultStoragePath: flowVaultStoragePath, pushToDrawDownSink: false) +} + +access(all) +fun testMaliciousSource() { + let hackerBalanceBefore = getBalance(address: hackerAccount.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 + log("[TEST] Hacker's Flow balance before: \(hackerBalanceBefore)") + + // deposit 1 Flow into the position + let openRes = executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position_malicious_source.cdc", + [1.0, flowVaultStoragePath, false], + hackerAccount + ) + Test.expect(openRes, Test.beSucceeded()) + + // withdraw 1337 Flow from the position + let withdrawRes = executeTransaction( + "./transactions/flow-credit-market/pool-management/withdraw_from_position.cdc", + [1 as UInt64, flowTokenIdentifier, 1337.0, true], + hackerAccount + ) + Test.expect(withdrawRes, Test.beFailed()) + + // check the balance of the hacker's account + let hackerBalance = getBalance(address: hackerAccount.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 + log("[TEST] Hacker's Flow balance: \(hackerBalance)") +} diff --git a/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc b/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc new file mode 100644 index 00000000..8c2f3f65 --- /dev/null +++ b/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc @@ -0,0 +1,239 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" + +import "DeFiActionsUtils" +import "DeFiActions" +import "FlowCreditMarket" + +import "MOET" +import "FlowToken" + +/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +/// THIS CONTRACT IS IN BETA AND IS NOT FINALIZED - INTERFACES MAY CHANGE AND/OR PENDING CHANGES MAY REQUIRE REDEPLOYMENT +/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +/// +/// FungibleTokenConnectors +/// +/// This contract defines generic DeFi Actions Sink & Source connector implementations for use with underlying Vault +/// Capabilities. These connectors can be used alone or in conjunction with other DeFi Actions connectors to create +/// complex DeFi workflows. +/// +access(all) contract AdversarialReentrancyConnectors { + + /// VaultSink + /// + /// A DeFiActions connector that deposits tokens into a Vault + /// + access(all) struct VaultSinkHacked : DeFiActions.Sink { + /// The Vault Type accepted by the Sink + access(all) let depositVaultType: Type + /// The maximum balance of the linked Vault, checked before executing a deposit + access(all) let maximumBalance: UFix64 + /// An optional identifier allowing protocols to identify stacked connector operations by defining a protocol- + /// specific Identifier to associated connectors on construction + access(contract) var uniqueID: DeFiActions.UniqueIdentifier? + /// An unentitled Capability on the Vault to which deposits are distributed + access(self) let depositVault: Capability<&{FungibleToken.Vault}> + + init( + max: UFix64?, + depositVault: Capability<&{FungibleToken.Vault}>, + uniqueID: DeFiActions.UniqueIdentifier? + ) { + pre { + depositVault.check(): "Provided invalid Capability" + DeFiActionsUtils.definingContractIsFungibleToken(depositVault.borrow()!.getType()): + "The contract defining Vault \(depositVault.borrow()!.getType().identifier) does not conform to FungibleToken contract interface" + (max ?? UFix64.max) > 0.0: + "Maximum balance must be greater than 0.0 if provided" + } + self.maximumBalance = max ?? UFix64.max // assume no maximum if none provided + self.uniqueID = uniqueID + self.depositVaultType = depositVault.borrow()!.getType() + self.depositVault = depositVault + } + + /// Returns a ComponentInfo struct containing information about this VaultSink and its inner DFA components + /// + /// @return a ComponentInfo struct containing information about this component and a list of ComponentInfo for + /// each inner component in the stack. + /// + access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { + return DeFiActions.ComponentInfo( + type: self.getType(), + id: self.id(), + innerComponents: [] + ) + } + /// Returns a copy of the struct's UniqueIdentifier, used in extending a stack to identify another connector in + /// a DeFiActions stack. See DeFiActions.align() for more information. + /// + /// @return a copy of the struct's UniqueIdentifier + /// + access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { + return self.uniqueID + } + /// Sets the UniqueIdentifier of this component to the provided UniqueIdentifier, used in extending a stack to + /// identify another connector in a DeFiActions stack. See DeFiActions.align() for more information. + /// + /// @param id: the UniqueIdentifier to set for this component + /// + access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { + self.uniqueID = id + } + /// Returns the Vault type accepted by this Sink + access(all) view fun getSinkType(): Type { + return self.depositVaultType + } + /// Returns an estimate of how much of the associated Vault can be accepted by this Sink + access(all) fun minimumCapacity(): UFix64 { + if let vault = self.depositVault.borrow() { + return vault.balance < self.maximumBalance ? self.maximumBalance - vault.balance : 0.0 + } + return 0.0 + } + /// Deposits up to the Sink's capacity from the provided Vault + access(all) fun depositCapacity(from: auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) { + let minimumCapacity = self.minimumCapacity() + if !self.depositVault.check() || minimumCapacity == 0.0 { + return + } + // deposit the lesser of the originating vault balance and minimum capacity + let capacity = minimumCapacity <= from.balance ? minimumCapacity : from.balance + self.depositVault.borrow()!.deposit(from: <-from.withdraw(amount: capacity)) + } + } + + access(all) resource LiveData { + /// Optional: Pool capability for recursive withdrawAndPull call + access(all) var recursivePool: Capability? + /// Optional: Position ID for recursive withdrawAndPull call + access(all) var recursivePositionID: UInt64? + + init() { self.recursivePositionID = nil; self.recursivePool = nil } + access(all) fun setRecursivePool(_ pool: Capability) { + self.recursivePool = pool + } + access(all) fun setRecursivePositionID(_ positionID: UInt64) { + self.recursivePositionID = positionID + } + } + access(all) fun createLiveData(): @LiveData { + return <- create LiveData() + } + + /// VaultSource + /// + /// A DeFiActions connector that withdraws tokens from a Vault + /// + access(all) struct VaultSourceHacked : DeFiActions.Source { + /// Returns the Vault type provided by this Source + access(all) let withdrawVaultType: Type + /// The minimum balance of the linked Vault + access(all) let minimumBalance: UFix64 + /// An optional identifier allowing protocols to identify stacked connector operations by defining a protocol- + /// specific Identifier to associated connectors on construction + access(contract) var uniqueID: DeFiActions.UniqueIdentifier? + /// An entitled Capability on the Vault from which withdrawals are sourced + access(self) let withdrawVault: Capability + // /// Optional: Pool capability for recursive withdrawAndPull call + // access(self) var recursivePool: Capability? + // /// Optional: Position ID for recursive withdrawAndPull call + // access(self) var recursivePositionID: &UInt64? + + access(all) let liveDataCap: Capability<&LiveData> + + init( + min: UFix64?, + withdrawVault: Capability, + uniqueID: DeFiActions.UniqueIdentifier?, + liveDataCap: Capability<&LiveData> + ) { + pre { + withdrawVault.check(): "Provided invalid Capability" + DeFiActionsUtils.definingContractIsFungibleToken(withdrawVault.borrow()!.getType()): + "The contract defining Vault \(withdrawVault.borrow()!.getType().identifier) does not conform to FungibleToken contract interface" + } + self.minimumBalance = min ?? 0.0 // assume no minimum if none provided + self.withdrawVault = withdrawVault + self.uniqueID = uniqueID + self.withdrawVaultType = withdrawVault.borrow()!.getType() + self.liveDataCap = liveDataCap + } + /// Returns a ComponentInfo struct containing information about this VaultSource and its inner DFA components + /// + /// @return a ComponentInfo struct containing information about this component and a list of ComponentInfo for + /// each inner component in the stack. + /// + access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { + return DeFiActions.ComponentInfo( + type: self.getType(), + id: self.id(), + innerComponents: [] + ) + } + /// Returns a copy of the struct's UniqueIdentifier, used in extending a stack to identify another connector in + /// a DeFiActions stack. See DeFiActions.align() for more information. + /// + /// @return a copy of the struct's UniqueIdentifier + /// + access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { + return self.uniqueID + } + /// Sets the UniqueIdentifier of this component to the provided UniqueIdentifier, used in extending a stack to + /// identify another connector in a DeFiActions stack. See DeFiActions.align() for more information. + /// + /// @param id: the UniqueIdentifier to set for this component + /// + access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { + self.uniqueID = id + } + /// Returns the Vault type provided by this Source + access(all) view fun getSourceType(): Type { + return self.withdrawVaultType + } + /// Returns an estimate of how much of the associated Vault can be provided by this Source + access(all) fun minimumAvailable(): UFix64 { + if let vault = self.withdrawVault.borrow() { + return self.minimumBalance < vault.balance ? vault.balance - self.minimumBalance : 0.0 + } + return 0.0 + } + + /// Withdraws the lesser of maxAmount or minimumAvailable(). If none is available, an empty Vault should be + /// returned + access(FungibleToken.Withdraw) fun withdrawAvailable(maxAmount: UFix64): @{FungibleToken.Vault} { + // If recursive withdrawAndPull is configured, call it first + log("VaultSource.withdrawAvailable called with maxAmount: \(maxAmount)") + log("=====Recursive pool: \(self.liveDataCap.check())") + let liveData = self.liveDataCap.borrow() ?? panic("cant borrow LiveData") + let poolRef = liveData.recursivePool!.borrow() ?? panic("cant borrow Recursive pool is nil") + // Call withdrawAndPull on the position + let recursiveVault <- poolRef.withdrawAndPull( + pid: liveData.recursivePositionID!, + // type: Type<@MOET.Vault>(), + type: Type<@FlowToken.Vault>(), + // type: tokenType, + amount: 900.0, + pullFromTopUpSource: false + ) + log("Recursive withdrawAndPull returned vault with balance: \(recursiveVault.balance)") + // If we got funds from the recursive call, return them + if recursiveVault.balance > 0.0 { + return <-recursiveVault + } + // Otherwise, destroy the empty vault and continue with normal withdrawal + destroy recursiveVault + + + // Normal vault withdrawal + let available = self.minimumAvailable() + if !self.withdrawVault.check() || available == 0.0 || maxAmount == 0.0 { + panic("Withdraw vault check failed") + } + // take the lesser between the available and maximum requested amount + let withdrawalAmount = available <= maxAmount ? available : maxAmount; + return <- self.withdrawVault.borrow()!.withdraw(amount: withdrawalAmount) + } + } +} diff --git a/cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc b/cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc new file mode 100644 index 00000000..377a318d --- /dev/null +++ b/cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc @@ -0,0 +1,58 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" + +import "DeFiActionsUtils" +import "DeFiActions" + +import "FlowToken" +import "MOET" + +access(all) contract AdversarialTypeSpoofingConnectors { + + access(all) struct VaultSourceFakeType : DeFiActions.Source { + access(all) let withdrawVaultType: Type + access(contract) var uniqueID: DeFiActions.UniqueIdentifier? + access(self) let withdrawVault: Capability + access(self) var fakeType: Type? + + init( + withdrawVault: Capability, + ) { + self.withdrawVault = withdrawVault + self.uniqueID = nil + self.withdrawVaultType = withdrawVault.borrow()!.getType() + self.fakeType = Type<@MOET.Vault>() + } + + access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { + return DeFiActions.ComponentInfo( + type: self.getType(), + id: self.id(), + innerComponents: [] + ) + } + access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { + return self.uniqueID + } + access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { + self.uniqueID = id + } + access(all) view fun getSourceType(): Type { + if let fakeType = self.fakeType { + return fakeType + } + return self.withdrawVaultType + } + /// Returns an estimate of how much of the associated Vault can be provided by this Source + access(all) fun minimumAvailable(): UFix64 { + return 0.0 + } + /// Withdraws the lesser of maxAmount or minimumAvailable(). If none is available, an empty Vault should be + /// returned + access(FungibleToken.Withdraw) fun withdrawAvailable(maxAmount: UFix64): @{FungibleToken.Vault} { + self.fakeType = nil + // take the lesser between the available and maximum requested amount + return <- self.withdrawVault.borrow()!.withdraw(amount: maxAmount) + } + } +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 55746e73..80badfbe 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -150,6 +150,21 @@ fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "AdversarialReentrancyConnectors", + path: "./contracts/AdversarialReentrancyConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + + err = Test.deployContract( + name: "AdversarialTypeSpoofingConnectors", + path: "./contracts/AdversarialTypeSpoofingConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) } /* --- Script Helpers --- */ diff --git a/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc b/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc new file mode 100644 index 00000000..56b10f92 --- /dev/null +++ b/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc @@ -0,0 +1,39 @@ +import "FlowCreditMarket" +import "FungibleToken" +import "MockFlowCreditMarketConsumer" + +/// Withdraw assets from an existing credit position, depositing to signer's Receiver +transaction( + positionID: UInt64, + tokenTypeIdentifier: String, + amount: UFix64, + pullFromTopUpSource: Bool +) { + let tokenType: Type + let pool: auth(FlowCreditMarket.EParticipant, FlowCreditMarket.EPosition) &FlowCreditMarket.Pool + let receiverRef: &{FungibleToken.Receiver} + + prepare(signer: auth(Storage, Capabilities, BorrowValue) &Account) { + self.tokenType = CompositeType(tokenTypeIdentifier) + ?? panic("Invalid tokenTypeIdentifier: ".concat(tokenTypeIdentifier)) + + // Borrow Pool with the entitlements required by withdrawAndPull + let poolCapability = MockFlowCreditMarketConsumer.getPoolCapability() + self.pool = poolCapability.borrow()! + // Get capability (NOT optional), then borrow a reference (optional) + let cap = signer.capabilities.get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver) + self.receiverRef = cap.borrow() + ?? panic("Could not borrow receiver ref from /public/flowTokenReceiver") + } + + execute { + let withdrawn <- self.pool.withdrawAndPull( + pid: positionID, + type: self.tokenType, + amount: amount, + pullFromTopUpSource: pullFromTopUpSource + ) + + self.receiverRef.deposit(from: <-withdrawn) + } +} diff --git a/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_hack.cdc b/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_hack.cdc new file mode 100644 index 00000000..a8c4e73c --- /dev/null +++ b/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_hack.cdc @@ -0,0 +1,95 @@ +import "FungibleToken" + +import "DeFiActions" +import "AdversarialReentrancyConnectors" + +import "MOET" +import "FlowToken" +import "MockFlowCreditMarketConsumer" +import "FlowCreditMarket" + +/// TEST TRANSACTION - DO NOT USE IN PRODUCTION +/// +/// Opens a Position with the amount of funds source from the Vault at the provided StoragePath and wraps it in a +/// MockFlowCreditMarketConsumer PositionWrapper +/// +transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: Bool){ + // the funds that will be used as collateral for a FlowCreditMarket loan + let collateral: @{FungibleToken.Vault} + // this DeFiActions Sink that will receive the loaned funds + let sink: {DeFiActions.Sink} + // DEBUG: this DeFiActions Source that will allow for the repayment of a loan if the position becomes undercollateralized + let source: {DeFiActions.Source} + // the signer's account in which to store a PositionWrapper + let account: auth(SaveValue) &Account + + prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + // configure a MOET Vault to receive the loaned amount + // if signer.storage.type(at: MOET.VaultStoragePath) == nil { + // // save a new MOET Vault + // signer.storage.save(<-MOET.createEmptyVault(vaultType: Type<@MOET.Vault>()), to: MOET.VaultStoragePath) + // // issue un-entitled Capability + // let vaultCap = signer.capabilities.storage.issue<&MOET.Vault>(MOET.VaultStoragePath) + // // publish receiver Capability, unpublishing any that may exist to prevent collision + // signer.capabilities.unpublish(MOET.VaultPublicPath) + // signer.capabilities.publish(vaultCap, at: MOET.VaultPublicPath) + // } + // assign a Vault Capability to be used in the VaultSink + let depositVaultCap = signer.capabilities.get<&{FungibleToken.Vault}>(MOET.VaultPublicPath) + let withdrawVaultCap = signer.capabilities.storage.issue(/storage/flowTokenVault) + assert(depositVaultCap.check(), + message: "Invalid MOET Vault public Capability issued - ensure the Vault is properly configured") + assert(withdrawVaultCap.check(), + message: "Invalid MOET Vault private Capability issued - ensure the Vault is properly configured") + + // withdraw the collateral from the signer's stored Vault + let collateralSource = signer.storage.borrow(from: vaultStoragePath) + ?? panic("Could not borrow reference to Vault from \(vaultStoragePath)") + self.collateral <- collateralSource.withdraw(amount: amount) + // construct the DeFiActions Sink that will receive the loaned amount + self.sink = AdversarialReentrancyConnectors.VaultSinkHacked( + max: nil, + depositVault: depositVaultCap, + uniqueID: nil + ) + let liveData <- AdversarialReentrancyConnectors.createLiveData() + + let storagePath = /storage/myLiveDataResource + signer.storage.save(<-liveData, to: storagePath) + + let liveDataCap = signer.capabilities.storage.issue<&AdversarialReentrancyConnectors.LiveData>(storagePath) + + self.source = AdversarialReentrancyConnectors.VaultSourceHacked( + min: nil, + withdrawVault: withdrawVaultCap, + uniqueID: nil, + liveDataCap: liveDataCap + ) + + // assign the signer's account enabling the execute block to save the wrapper + self.account = signer + } + + execute { + // open a position & save in the Wrapper + let wrapper <- MockFlowCreditMarketConsumer.createPositionWrapper( + collateral: <-self.collateral, + issuanceSink: self.sink, + repaymentSource: self.source, + pushToDrawDownSink: pushToDrawDownSink + ) + let poolCapability = MockFlowCreditMarketConsumer.getPoolCapability() + log("Pool capability: \(poolCapability.check())") + let sourceRef = self.source as! AdversarialReentrancyConnectors.VaultSourceHacked + + let liveData = sourceRef.liveDataCap.borrow() ?? panic("cant borrow LiveData") + liveData.setRecursivePool(poolCapability) + liveData.setRecursivePositionID(wrapper.positionID) + // sourceRef.setRecursivePool(poolCapability) + // let test2 = &wrapper.positionID + // sourceRef.setRecursivePositionID(test2) + // sourceRef.setRecursivePositionID(&wrapper.positionID) + // save the wrapper into the signer's account - reverts on storage collision + self.account.storage.save(<-wrapper, to: MockFlowCreditMarketConsumer.WrapperStoragePath) + } +} diff --git a/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_malicious_source.cdc b/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_malicious_source.cdc new file mode 100644 index 00000000..d0c3f996 --- /dev/null +++ b/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_malicious_source.cdc @@ -0,0 +1,75 @@ +import "FungibleToken" + +import "DeFiActions" +import "FungibleTokenConnectors" +import "AdversarialTypeSpoofingConnectors" + +import "MOET" +import "FlowToken" +import "MockFlowCreditMarketConsumer" +import "FlowCreditMarket" + +/// TEST TRANSACTION - DO NOT USE IN PRODUCTION +/// +/// Opens a Position with the amount of funds source from the Vault at the provided StoragePath and wraps it in a +/// MockFlowCreditMarketConsumer PositionWrapper +/// +transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: Bool) { + + // the funds that will be used as collateral for a FlowCreditMarket loan + let collateral: @{FungibleToken.Vault} + // this DeFiActions Sink that will receive the loaned funds + let sink: {DeFiActions.Sink} + // DEBUG: this DeFiActions Source that will allow for the repayment of a loan if the position becomes undercollateralized + let source: {DeFiActions.Source} + // the signer's account in which to store a PositionWrapper + let account: auth(SaveValue) &Account + + prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + // configure a MOET Vault to receive the loaned amount + if signer.storage.type(at: MOET.VaultStoragePath) == nil { + // save a new MOET Vault + signer.storage.save(<-MOET.createEmptyVault(vaultType: Type<@MOET.Vault>()), to: MOET.VaultStoragePath) + // issue un-entitled Capability + let vaultCap = signer.capabilities.storage.issue<&MOET.Vault>(MOET.VaultStoragePath) + // publish receiver Capability, unpublishing any that may exist to prevent collision + signer.capabilities.unpublish(MOET.VaultPublicPath) + signer.capabilities.publish(vaultCap, at: MOET.VaultPublicPath) + } + // assign a Vault Capability to be used in the VaultSink + let depositVaultCap = signer.capabilities.get<&{FungibleToken.Vault}>(MOET.VaultPublicPath) + let withdrawVaultCap = signer.capabilities.storage.issue(/storage/flowTokenVault) + assert(depositVaultCap.check(), + message: "Invalid MOET Vault public Capability issued - ensure the Vault is properly configured") + assert(withdrawVaultCap.check(), + message: "Invalid MOET Vault private Capability issued - ensure the Vault is properly configured") + + // withdraw the collateral from the signer's stored Vault + let collateralSource = signer.storage.borrow(from: vaultStoragePath) + ?? panic("Could not borrow reference to Vault from \(vaultStoragePath)") + self.collateral <- collateralSource.withdraw(amount: amount) + // construct the DeFiActions Sink that will receive the loaned amount + self.sink = FungibleTokenConnectors.VaultSink( + max: nil, + depositVault: depositVaultCap, + uniqueID: nil + ) + self.source = AdversarialTypeSpoofingConnectors.VaultSourceFakeType( + withdrawVault: withdrawVaultCap, + ) + // assign the signer's account enabling the execute block to save the wrapper + self.account = signer + } + + execute { + // open a position & save in the Wrapper + let wrapper <- MockFlowCreditMarketConsumer.createPositionWrapper( + collateral: <-self.collateral, + issuanceSink: self.sink, + repaymentSource: self.source, + pushToDrawDownSink: pushToDrawDownSink + ) + // save the wrapper into the signer's account - reverts on storage collision + self.account.storage.save(<-wrapper, to: MockFlowCreditMarketConsumer.WrapperStoragePath) + } +} diff --git a/flow.json b/flow.json index a72b42db..a54415bd 100644 --- a/flow.json +++ b/flow.json @@ -69,6 +69,18 @@ "aliases": { "testing": "0000000000000007" } + }, + "AdversarialReentrancyConnectors": { + "source": "./cadence/tests/contracts/AdversarialReentrancyConnectors.cdc", + "aliases": { + "testing": "0000000000000008" + } + }, + "AdversarialTypeSpoofingConnectors": { + "source": "./cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc", + "aliases": { + "testing": "0000000000000008" + } } }, "dependencies": { @@ -243,4 +255,4 @@ ] } } -} \ No newline at end of file +} From 670f1542484d94e483867794957bc1d25e79be9f Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:42:22 -0500 Subject: [PATCH 12/18] fix internal calls --- cadence/contracts/FlowCreditMarket.cdc | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index a7160953..3de3e9cc 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -2776,7 +2776,6 @@ access(all) contract FlowCreditMarket { ) var canWithdraw = false - var usedTopUp = false if requiredDeposit == 0.0 { // We can service this withdrawal without any top up @@ -2807,7 +2806,6 @@ access(all) contract FlowCreditMarket { pid: pid, 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 @@ -2815,7 +2813,6 @@ access(all) contract FlowCreditMarket { pid: pid, from: <-pulledVault ) - usedTopUp = pulledAmount > 0.0 } } } @@ -3425,20 +3422,13 @@ access(all) contract FlowCreditMarket { if maxDeposit >= queuedAmount { // We can deposit all of the queued deposit, so just do it and remove it from the queue - self.depositAndPush( - pid: pid, - from: <-queuedVault, - pushToDrawDownSink: false - ) + + self._depositEffectsOnly(pid: pid, from: <-queuedVault) } else { // We can only deposit part of the queued deposit, so do that and leave the rest in the queue // for the next time we run. let depositVault <- queuedVault.withdraw(amount: maxDeposit) - self.depositAndPush( - pid: pid, - from: <-depositVault, - pushToDrawDownSink: false - ) + self._depositEffectsOnly(pid: pid, from: <-depositVault) // We need to update the queued vault to reflect the amount we used up position.queuedDeposits[depositType] <-! queuedVault From d1c05450d4dc1f5dad9061d367c7d2d25030a14b Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:40:58 -0500 Subject: [PATCH 13/18] add test for async update position --- cadence/contracts/FlowCreditMarket.cdc | 11 +++- cadence/tests/async_update_position_test.cdc | 55 +++++++++++++++++++ .../pool-management/async_update_position.cdc | 18 ++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 cadence/tests/async_update_position_test.cdc create mode 100644 cadence/tests/transactions/flow-credit-market/pool-management/async_update_position.cdc diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 3de3e9cc..a81087a9 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -3413,8 +3413,10 @@ access(all) contract FlowCreditMarket { self._lockPosition(pid) let position = self._borrowPosition(pid: pid) + // store types to avoid iterating while mutating + let depositTypes = position.queuedDeposits.keys // First check queued deposits, their addition could affect the rebalance we attempt later - for depositType in position.queuedDeposits.keys { + for depositType in depositTypes { let queuedVault <- position.queuedDeposits.remove(key: depositType)! let queuedAmount = queuedVault.balance let depositTokenState = self._borrowUpdatedTokenState(type: depositType) @@ -3431,7 +3433,12 @@ access(all) contract FlowCreditMarket { self._depositEffectsOnly(pid: pid, from: <-depositVault) // We need to update the queued vault to reflect the amount we used up - position.queuedDeposits[depositType] <-! queuedVault + if let existing <- position.queuedDeposits.remove(key: depositType) { + existing.deposit(from: <-queuedVault) + position.queuedDeposits[depositType] <-! existing + } else { + position.queuedDeposits[depositType] <-! queuedVault + } } } diff --git a/cadence/tests/async_update_position_test.cdc b/cadence/tests/async_update_position_test.cdc new file mode 100644 index 00000000..99ec4a20 --- /dev/null +++ b/cadence/tests/async_update_position_test.cdc @@ -0,0 +1,55 @@ +import Test +import BlockchainHelpers +import "FlowCreditMarket" + +import "test_helpers.cdc" + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun setup() { + deployContracts() + + let betaTxResult = grantBeta(PROTOCOL_ACCOUNT, CONSUMER_ACCOUNT) + + Test.expect(betaTxResult, Test.beSucceeded()) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun testUpdatePosition() { + // Test.reset(to: snapshot) + let initialPrice = 1.0 + let priceIncreasePct: UFix64 = 1.2 + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOET_TOKEN_IDENTIFIER, price: initialPrice) + + 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.0, + depositCapacityCap: 1_000.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + + createWrappedPosition(signer: user, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + + // increase price + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice * priceIncreasePct) + + depositToWrappedPosition(signer: user, amount: 600.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + + let updatePositionRes = _executeTransaction( + "./transactions/flow-credit-market/pool-management/async_update_position.cdc", + [ 0 as UInt64 ], + PROTOCOL_ACCOUNT + ) + Test.expect(updatePositionRes, Test.beSucceeded()) +} diff --git a/cadence/tests/transactions/flow-credit-market/pool-management/async_update_position.cdc b/cadence/tests/transactions/flow-credit-market/pool-management/async_update_position.cdc new file mode 100644 index 00000000..ff2d2174 --- /dev/null +++ b/cadence/tests/transactions/flow-credit-market/pool-management/async_update_position.cdc @@ -0,0 +1,18 @@ +import "FlowCreditMarket" + +/// Async update a FlowCreditMarket position by it's Position ID +/// +/// @param pid: The position ID to update +/// +transaction(pid: UInt64) { + let pool: auth(FlowCreditMarket.EImplementation) &FlowCreditMarket.Pool + + prepare(signer: auth(BorrowValue) &Account) { + self.pool = signer.storage.borrow(from: FlowCreditMarket.PoolStoragePath) + ?? panic("Could not borrow reference to Pool from \(FlowCreditMarket.PoolStoragePath) - ensure a Pool has been configured") + } + + execute { + self.pool.asyncUpdatePosition(pid: pid) + } +} From 78ebc9c6d934caba323bdf365f6baf2a8dcdd143 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:18:51 -0500 Subject: [PATCH 14/18] address PR comments --- cadence/contracts/FlowCreditMarket.cdc | 25 ++- ...rsarial_recursive_withdraw_source_test.cdc | 168 +++++------------- .../tests/adversarial_type_spoofing_test.cdc | 2 +- cadence/tests/async_update_position_test.cdc | 5 +- .../AdversarialReentrancyConnectors.cdc | 17 +- .../AdversarialTypeSpoofingConnectors.cdc | 3 + ...=> create_wrapped_position_reentrancy.cdc} | 28 ++- ...eate_wrapped_position_spoofing_source.cdc} | 5 + 8 files changed, 86 insertions(+), 167 deletions(-) rename cadence/tests/transactions/mock-flow-credit-market-consumer/{create_wrapped_position_hack.cdc => create_wrapped_position_reentrancy.cdc} (74%) rename cadence/tests/transactions/mock-flow-credit-market-consumer/{create_wrapped_position_malicious_source.cdc => create_wrapped_position_spoofing_source.cdc} (92%) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index a81087a9..15adf187 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -2587,7 +2587,12 @@ access(all) contract FlowCreditMarket { pushToDrawDownSink: false ) } - + /// Applies the state transitions for depositing `from` into `pid`, without doing any of the + /// surrounding orchestration (locking, health checks, rebalancing, or caller authorization). + /// + /// This helper is intentionally effects-only: it *mutates* Pool/Position state and consumes `from`, + /// but assumes all higher-level preconditions have already been enforced by the caller. + /// /// TODO(jord): ~100-line function - consider refactoring. access(self) fun _depositEffectsOnly( pid: UInt64, @@ -2829,7 +2834,6 @@ 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") } @@ -2856,7 +2860,7 @@ access(all) contract FlowCreditMarket { let postHealth = self.positionHealth(pid: pid) assert( position.minHealth <= postHealth, - message: "Position is overdrawn" + message: "Post-withdrawal position health (\(postHealth)) is below min health threshold (\(position.minHealth))" ) // Queue for update if necessary @@ -3290,6 +3294,14 @@ access(all) contract FlowCreditMarket { self._rebalancePositionNoLock(pid: pid, force: force) self._unlockPosition(pid) } + + /// Attempts to rebalance a position toward its configured `targetHealth` without acquiring + /// or releasing the position lock. This function performs *best-effort* rebalancing and may + /// partially rebalance or no-op depending on available sinks/sources and their capacity. + /// + /// This helper is intentionally "no-lock" and "effects-only" with respect to orchestration. + /// Callers are responsible for acquiring and releasing the position lock and for enforcing + /// any higher-level invariants. access(self) fun _rebalancePositionNoLock(pid: UInt64, force: Bool) { if self.debugLogging { log(" [CONTRACT] rebalancePosition(pid: \(pid), force: \(force))") @@ -3433,12 +3445,7 @@ access(all) contract FlowCreditMarket { self._depositEffectsOnly(pid: pid, from: <-depositVault) // We need to update the queued vault to reflect the amount we used up - if let existing <- position.queuedDeposits.remove(key: depositType) { - existing.deposit(from: <-queuedVault) - position.queuedDeposits[depositType] <-! existing - } else { - position.queuedDeposits[depositType] <-! queuedVault - } + position.queuedDeposits[depositType] <-! queuedVault } } diff --git a/cadence/tests/adversarial_recursive_withdraw_source_test.cdc b/cadence/tests/adversarial_recursive_withdraw_source_test.cdc index 9c7ef0d7..daee392d 100644 --- a/cadence/tests/adversarial_recursive_withdraw_source_test.cdc +++ b/cadence/tests/adversarial_recursive_withdraw_source_test.cdc @@ -56,46 +56,70 @@ fun setup() { access(all) fun testRecursiveWithdrawSource() { + // Ensure we always run from the same post-setup chain state. + // This makes the test deterministic across multiple runs. if snapshot < getCurrentBlockHeight() { Test.reset(to: snapshot) } - // Setup user 1 - Giving pool 10000 Flow to borrow + // ------------------------------------------------------------------------- + // Seed pool liquidity / establish a baseline lender position + // ------------------------------------------------------------------------- + // Create a separate account (user1) that funds the pool by opening a position + // with a large initial deposit. This ensures the pool has reserves available + // for subsequent borrow/withdraw paths in this test. let user1 = Test.createAccount() setupMoetVault(user1, beFailed: false) mintMoet(signer: protocolAccount, to: user1.address, amount: 10000.0, beFailed: false) mintFlow(to: user1, amount: 10000.0) grantPoolCapToConsumer() - + let initialDeposit1 = 10000.0 - createWrappedPosition(signer: user1, amount: initialDeposit1, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: false) + createWrappedPosition( + signer: user1, + amount: initialDeposit1, + vaultStoragePath: /storage/flowTokenVault, + pushToDrawDownSink: false + ) log("[TEST] USER1 POSITION ID: \(positionID)") - - // ============================== - // Open a position with pushToDrawDownSink=true to get some MOET borrowed + // ------------------------------------------------------------------------- + // Attempt a reentrancy / recursive-withdraw scenario + // ------------------------------------------------------------------------- + // Open a new position for `userAccount` using a special transaction that wires + // a *malicious* topUpSource (or wrapper behavior) designed to attempt recursion + // during `withdrawAndPull(..., pullFromTopUpSource: true)`. + // + // The goal is to prove the pool rejects the attempt (e.g. via position lock / + // reentrancy guard), rather than allowing nested withdraw/deposit effects. let openRes = executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position_hack.cdc", + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position_reentrancy.cdc", [positionFundingAmount, flowVaultStoragePath, false], userAccount ) Test.expect(openRes, Test.beSucceeded()) - // Get position ID from events + // Read the newly opened position id from the latest Opened event. var evts = Test.eventsOfType(Type()) let openedEvt = evts[evts.length - 1] as! FlowCreditMarket.Opened positionID = openedEvt.pid log("[TEST] Position opened with ID: \(positionID)") + // Log balances for debugging context only (not assertions). let remainingFlow = getBalance(address: userAccount.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 - log("[TEST] Remaining Flow: \(remainingFlow)") + log("[TEST] User FLOW balance after open: \(remainingFlow)") let moetBalance = getBalance(address: userAccount.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - log("[TEST] Remaining MOET: \(moetBalance)") - - // put 1000 Flow into the position - // somehow only 650 get put into it? - // took 500 out of the position - + log("[TEST] User MOET balance after open: \(moetBalance)") + + // ------------------------------------------------------------------------- + // Trigger the vulnerable path: withdraw with pullFromTopUpSource=true + // ------------------------------------------------------------------------- + // This withdrawal is intentionally oversized so it cannot be satisfied purely + // from the position’s current available balance. The pool will attempt to pull + // funds from the configured topUpSource to keep the position above minHealth. + // + // In this test, the topUpSource behavior is adversarial: it attempts to re-enter + // the pool during the pull/deposit flow. We expect the transaction to fail. let withdrawRes = executeTransaction( "./transactions/flow-credit-market/pool-management/withdraw_from_position.cdc", [positionID, flowTokenIdentifier, 1500.0, true], // pullFromTopUpSource: true @@ -103,117 +127,9 @@ fun testRecursiveWithdrawSource() { ) Test.expect(withdrawRes, Test.beFailed()) + // Log post-failure balances for debugging context. let currentFlow = getBalance(address: userAccount.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 - log("[TEST] Current Flow: \(currentFlow)") + log("[TEST] User FLOW balance after failed withdraw: \(currentFlow)") let currentMoet = getBalance(address: userAccount.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - log("[TEST] Current MOET: \(currentMoet)") - - - - - // let withdrawRes2 = executeTransaction( - // "./transactions/flow-credit-market/pool-management/withdraw_from_position.cdc", - // [positionID, flowTokenIdentifier, 10000.0, false], // pullFromTopUpSource: true - // user1 - // ) - // Test.expect(withdrawRes2, Test.beSucceeded()) - // log("[TEST] Withdrawal 2 succeeded") - - // log info about the position - // log("[TEST] Position info: \(getPositionInfo(pid: positionID, beFailed: false))") - - // // Get initial available balance without topUpSource - // let initialAvailable = getAvailableBalance( - // pid: positionID, - // vaultIdentifier: moetTokenIdentifier, - // pullFromTopUpSource: false, - // beFailed: false - // ) - // log("[TEST] Initial available balance (no topUp): \(initialAvailable)") - - // // Calculate a withdrawal amount that will require topUpSource - // // We need to withdraw more than what's available without topUpSource - // let largeWithdrawAmount = initialAvailable * 1.00000001 - // // let largeWithdrawAmount = 110.0 - // log("[TEST] Large withdrawal amount (requires topUp): \(largeWithdrawAmount)") - - // // Calculate a smaller withdrawal amount that does NOT require topUpSource - // // This should be less than the available balance - // let smallWithdrawAmount = initialAvailable * 0.3 - // log("[TEST] Small withdrawal amount (no topUp needed): \(smallWithdrawAmount)") - - // // Verify that the large amount requires topUpSource (when topUpSource provides MOET) - // let requiredForLarge = fundsRequiredForTargetHealthAfterWithdrawing( - // pid: positionID, - // depositType: flowTokenIdentifier, - // targetHealth: UFix128(minHealth), - // withdrawType: moetTokenIdentifier, - // withdrawAmount: largeWithdrawAmount, - // beFailed: false - // ) - // log("[TEST] Required deposit for large withdrawal (with MOET topUp): \(requiredForLarge)") - // Test.assert(requiredForLarge > 0.0, message: "Large withdrawal should require topUpSource") - - // // Verify that the small amount does NOT require topUpSource - // let requiredForSmall = fundsRequiredForTargetHealthAfterWithdrawing( - // pid: positionID, - // depositType: flowTokenIdentifier, - // targetHealth: UFix128(minHealth), - // withdrawType: moetTokenIdentifier, - // withdrawAmount: smallWithdrawAmount, - // beFailed: false - // ) - // log("[TEST] Required deposit for small withdrawal: \(requiredForSmall)") - // Test.assert(requiredForSmall == 0.0, message: "Small withdrawal should NOT require topUpSource") - - // // Also check what would be required if topUpSource provides Flow (for our recursive source) - // let requiredForLargeWithFlow = fundsRequiredForTargetHealthAfterWithdrawing( - // pid: positionID, - // depositType: flowTokenIdentifier, - // targetHealth: UFix128(minHealth), - // withdrawType: moetTokenIdentifier, - // withdrawAmount: largeWithdrawAmount, - // beFailed: false - // ) - // log("[TEST] Required deposit for large withdrawal (with Flow topUp): \(requiredForLargeWithFlow)") - - // // Ensure user has enough Flow in their vault for the topUpSource - // // The user should have some Flow remaining after opening the position - // let userFlowBalance = getBalance(address: userAccount.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 - // log("[TEST] User Flow balance: \(userFlowBalance)") - - // // Ensure user has enough Flow for the topUpSource (we need Flow, not MOET, since our source provides Flow) - // // Mint additional Flow if needed - // if userFlowBalance < requiredForLargeWithFlow { - // let additionalFlow = requiredForLargeWithFlow - userFlowBalance + 100.0 // Add buffer - // mintFlow(to: userAccount, amount: additionalFlow) - // log("[TEST] Minted \(additionalFlow) Flow to user") - // } - - // // Now make a withdrawal with the large amount that requires pullFromTopUpSource - // // This should trigger the recursive source's withdrawAvailable, which will - // // call withdrawAndPull with the small amount (without pullFromTopUpSource) - // // log("[TEST] Making large withdrawal that requires topUpSource...") - - // let withdrawRes = executeTransaction( - // "./transactions/flow-credit-market/pool-management/withdraw_from_position.cdc", - // [positionID, moetTokenIdentifier, largeWithdrawAmount, true], // pullFromTopUpSource: true - // userAccount - // ) - - // // The transaction should succeed because: - // // 1. withdrawAndPull is called with largeWithdrawAmount and pullFromTopUpSource: true - // // 2. This triggers the recursive source's withdrawAvailable - // // 3. The recursive source calls withdrawAndPull with smallWithdrawAmount and pullFromTopUpSource: false - // // 4. This nested call succeeds because smallWithdrawAmount doesn't require topUpSource - - // Test.expect(withdrawRes, Test.beSucceeded()) - // log("[TEST] Large withdrawal succeeded with recursive source") - - // // Verify the position health is still above minimum - let finalHealth = getPositionHealth(pid: positionID, beFailed: false) - log("[TEST] Final position health: \(finalHealth)") - // Test.assert(finalHealth >= UFix128(minHealth), message: "Position health should be at or above minimum") - - // log("==============================") + log("[TEST] User MOET balance after failed withdraw: \(currentMoet)") } diff --git a/cadence/tests/adversarial_type_spoofing_test.cdc b/cadence/tests/adversarial_type_spoofing_test.cdc index d22b3722..3134a6e6 100644 --- a/cadence/tests/adversarial_type_spoofing_test.cdc +++ b/cadence/tests/adversarial_type_spoofing_test.cdc @@ -57,7 +57,7 @@ fun testMaliciousSource() { // deposit 1 Flow into the position let openRes = executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position_malicious_source.cdc", + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position_spoofing_source.cdc", [1.0, flowVaultStoragePath, false], hackerAccount ) diff --git a/cadence/tests/async_update_position_test.cdc b/cadence/tests/async_update_position_test.cdc index 99ec4a20..78b48b40 100644 --- a/cadence/tests/async_update_position_test.cdc +++ b/cadence/tests/async_update_position_test.cdc @@ -19,9 +19,8 @@ fun setup() { access(all) fun testUpdatePosition() { - // Test.reset(to: snapshot) let initialPrice = 1.0 - let priceIncreasePct: UFix64 = 1.2 + let priceIncreaseFactor: UFix64 = 1.2 setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice) setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOET_TOKEN_IDENTIFIER, price: initialPrice) @@ -42,7 +41,7 @@ fun testUpdatePosition() { createWrappedPosition(signer: user, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) // increase price - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice * priceIncreasePct) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice * priceIncreaseFactor) depositToWrappedPosition(signer: user, amount: 600.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) diff --git a/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc b/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc index 8c2f3f65..ac736f40 100644 --- a/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc +++ b/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc @@ -9,15 +9,16 @@ import "MOET" import "FlowToken" /// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -/// THIS CONTRACT IS IN BETA AND IS NOT FINALIZED - INTERFACES MAY CHANGE AND/OR PENDING CHANGES MAY REQUIRE REDEPLOYMENT +/// THIS IS A TESTING CONTRACT THAT SHOULD NOT BE USED IN PRODUCTION /// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! /// -/// FungibleTokenConnectors -/// -/// This contract defines generic DeFi Actions Sink & Source connector implementations for use with underlying Vault -/// Capabilities. These connectors can be used alone or in conjunction with other DeFi Actions connectors to create -/// complex DeFi workflows. +/// AdversarialReentrancyConnectors /// +/// This contract holds malicious DeFi connectors which implement a re-entrancy attack. +/// When a user withdraws from their position, they can optionally pull from their configured top-up source to help fund the withdrawal. +/// This contract implements a malicious source which attempts to withdraw from the same position again +/// when it is asked to provide funds for the outer withdrawal. +/// If unaccounted for, this could allow an attacker to withdraw more than their available balance from the shared Pool reserve. access(all) contract AdversarialReentrancyConnectors { /// VaultSink @@ -136,10 +137,6 @@ access(all) contract AdversarialReentrancyConnectors { access(contract) var uniqueID: DeFiActions.UniqueIdentifier? /// An entitled Capability on the Vault from which withdrawals are sourced access(self) let withdrawVault: Capability - // /// Optional: Pool capability for recursive withdrawAndPull call - // access(self) var recursivePool: Capability? - // /// Optional: Position ID for recursive withdrawAndPull call - // access(self) var recursivePositionID: &UInt64? access(all) let liveDataCap: Capability<&LiveData> diff --git a/cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc b/cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc index 377a318d..ca075c09 100644 --- a/cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc +++ b/cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc @@ -7,6 +7,9 @@ import "DeFiActions" import "FlowToken" import "MOET" +/// This contract holds a DeFiActions Source which spoofs its token type. +/// (`getSourceType` returns a different type than what is actually returned in a withdrawal.) +/// This is used to verify that FlowCreditMarket does not blindly trust the purported token type of top-up vaults. access(all) contract AdversarialTypeSpoofingConnectors { access(all) struct VaultSourceFakeType : DeFiActions.Source { diff --git a/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_hack.cdc b/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_reentrancy.cdc similarity index 74% rename from cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_hack.cdc rename to cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_reentrancy.cdc index a8c4e73c..8c8990ed 100644 --- a/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_hack.cdc +++ b/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_reentrancy.cdc @@ -1,6 +1,7 @@ import "FungibleToken" import "DeFiActions" +import "FungibleTokenConnectors" import "AdversarialReentrancyConnectors" import "MOET" @@ -10,8 +11,13 @@ import "FlowCreditMarket" /// TEST TRANSACTION - DO NOT USE IN PRODUCTION /// -/// Opens a Position with the amount of funds source from the Vault at the provided StoragePath and wraps it in a -/// MockFlowCreditMarketConsumer PositionWrapper +/// Opens a FlowCreditMarket position using collateral withdrawn from the signer’s vault and +/// wraps it in a `MockFlowCreditMarketConsumer.PositionWrapper`. +/// +/// This transaction intentionally wires an **adversarial DeFiActions.Source** that attempts +/// to re-enter the Pool during `withdrawAndPull` flows. It is used to validate that the Pool’s +/// reentrancy protections (position locks) correctly reject recursive deposit/withdraw behavior. +/// /// transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: Bool){ // the funds that will be used as collateral for a FlowCreditMarket loan @@ -24,16 +30,6 @@ transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: B let account: auth(SaveValue) &Account prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { - // configure a MOET Vault to receive the loaned amount - // if signer.storage.type(at: MOET.VaultStoragePath) == nil { - // // save a new MOET Vault - // signer.storage.save(<-MOET.createEmptyVault(vaultType: Type<@MOET.Vault>()), to: MOET.VaultStoragePath) - // // issue un-entitled Capability - // let vaultCap = signer.capabilities.storage.issue<&MOET.Vault>(MOET.VaultStoragePath) - // // publish receiver Capability, unpublishing any that may exist to prevent collision - // signer.capabilities.unpublish(MOET.VaultPublicPath) - // signer.capabilities.publish(vaultCap, at: MOET.VaultPublicPath) - // } // assign a Vault Capability to be used in the VaultSink let depositVaultCap = signer.capabilities.get<&{FungibleToken.Vault}>(MOET.VaultPublicPath) let withdrawVaultCap = signer.capabilities.storage.issue(/storage/flowTokenVault) @@ -47,7 +43,7 @@ transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: B ?? panic("Could not borrow reference to Vault from \(vaultStoragePath)") self.collateral <- collateralSource.withdraw(amount: amount) // construct the DeFiActions Sink that will receive the loaned amount - self.sink = AdversarialReentrancyConnectors.VaultSinkHacked( + self.sink = FungibleTokenConnectors.VaultSink( max: nil, depositVault: depositVaultCap, uniqueID: nil @@ -85,11 +81,7 @@ transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: B let liveData = sourceRef.liveDataCap.borrow() ?? panic("cant borrow LiveData") liveData.setRecursivePool(poolCapability) liveData.setRecursivePositionID(wrapper.positionID) - // sourceRef.setRecursivePool(poolCapability) - // let test2 = &wrapper.positionID - // sourceRef.setRecursivePositionID(test2) - // sourceRef.setRecursivePositionID(&wrapper.positionID) - // save the wrapper into the signer's account - reverts on storage collision + self.account.storage.save(<-wrapper, to: MockFlowCreditMarketConsumer.WrapperStoragePath) } } diff --git a/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_malicious_source.cdc b/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_spoofing_source.cdc similarity index 92% rename from cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_malicious_source.cdc rename to cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_spoofing_source.cdc index d0c3f996..efb7bb9b 100644 --- a/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_malicious_source.cdc +++ b/cadence/tests/transactions/mock-flow-credit-market-consumer/create_wrapped_position_spoofing_source.cdc @@ -14,6 +14,11 @@ import "FlowCreditMarket" /// Opens a Position with the amount of funds source from the Vault at the provided StoragePath and wraps it in a /// MockFlowCreditMarketConsumer PositionWrapper /// +/// This transaction intentionally wires an **adversarial DeFiActions.Source** that attempts +/// to **spoof token types** during withdrawal. It is used to verify that the Pool and its +/// connectors correctly enforce runtime type checks and do not accept malformed or +/// mis-declared vaults from external Sources. +/// transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: Bool) { // the funds that will be used as collateral for a FlowCreditMarket loan From 4ab129fe70e5fae75f40dac1e8f2b475d8f63331 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:55:50 -0500 Subject: [PATCH 15/18] fix tests --- ...rsarial_recursive_withdraw_source_test.cdc | 10 ++-- .../tests/adversarial_type_spoofing_test.cdc | 12 ++-- .../withdraw_from_position.cdc | 11 ++-- ...ncy.cdc => create_position_reentrancy.cdc} | 59 +++++++++++++------ ...dc => create_position_spoofing_source.cdc} | 56 +++++++++++++----- 5 files changed, 98 insertions(+), 50 deletions(-) rename cadence/tests/transactions/position-manager/{create_wrapped_position_reentrancy.cdc => create_position_reentrancy.cdc} (51%) rename cadence/tests/transactions/position-manager/{create_wrapped_position_spoofing_source.cdc => create_position_spoofing_source.cdc} (52%) diff --git a/cadence/tests/adversarial_recursive_withdraw_source_test.cdc b/cadence/tests/adversarial_recursive_withdraw_source_test.cdc index daee392d..6ad4d1e6 100644 --- a/cadence/tests/adversarial_recursive_withdraw_source_test.cdc +++ b/cadence/tests/adversarial_recursive_withdraw_source_test.cdc @@ -16,7 +16,6 @@ access(all) let userAccount = Test.createAccount() access(all) let flowTokenIdentifier = "A.0000000000000003.FlowToken.Vault" access(all) let moetTokenIdentifier = "A.0000000000000007.MOET.Vault" access(all) let flowVaultStoragePath = /storage/flowTokenVault -access(all) let wrapperStoragePath = /storage/flowCreditMarketPositionWrapper access(all) let flowBorrowFactor = 1.0 access(all) let flowStartPrice = 1.0 @@ -29,8 +28,8 @@ access(all) fun setup() { deployContracts() - let betaTxResult = grantBeta(protocolAccount, protocolConsumerAccount) - Test.expect(betaTxResult, Test.beSucceeded()) + grantBetaPoolParticipantAccess(protocolAccount, protocolConsumerAccount) + grantBetaPoolParticipantAccess(protocolAccount, userAccount) // Price setup setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: flowStartPrice) @@ -72,10 +71,9 @@ fun testRecursiveWithdrawSource() { setupMoetVault(user1, beFailed: false) mintMoet(signer: protocolAccount, to: user1.address, amount: 10000.0, beFailed: false) mintFlow(to: user1, amount: 10000.0) - grantPoolCapToConsumer() let initialDeposit1 = 10000.0 - createWrappedPosition( + createPosition( signer: user1, amount: initialDeposit1, vaultStoragePath: /storage/flowTokenVault, @@ -93,7 +91,7 @@ fun testRecursiveWithdrawSource() { // The goal is to prove the pool rejects the attempt (e.g. via position lock / // reentrancy guard), rather than allowing nested withdraw/deposit effects. let openRes = executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position_reentrancy.cdc", + "./transactions/position-manager/create_position_reentrancy.cdc", [positionFundingAmount, flowVaultStoragePath, false], userAccount ) diff --git a/cadence/tests/adversarial_type_spoofing_test.cdc b/cadence/tests/adversarial_type_spoofing_test.cdc index 3134a6e6..a58825c8 100644 --- a/cadence/tests/adversarial_type_spoofing_test.cdc +++ b/cadence/tests/adversarial_type_spoofing_test.cdc @@ -5,7 +5,6 @@ import "MOET" import "FlowCreditMarket" import "DeFiActions" import "DeFiActionsUtils" -import "MockFlowCreditMarketConsumer" import "FlowToken" import "test_helpers.cdc" import "FungibleToken" @@ -17,16 +16,13 @@ access(all) var hackerAccount = Test.getAccount(0x0000000000000008) access(all) let flowTokenIdentifier = "A.0000000000000003.FlowToken.Vault" access(all) let moetTokenIdentifier = "A.0000000000000007.MOET.Vault" access(all) let flowVaultStoragePath = /storage/flowTokenVault -access(all) let wrapperStoragePath = /storage/flowCreditMarketPositionWrapper access(all) fun setup() { deployContracts() - let betaTxResult1 = grantBeta(protocolAccount, liquidityAccount) - Test.expect(betaTxResult1, Test.beSucceeded()) - let betaTxResult2 = grantBeta(protocolAccount, hackerAccount) - Test.expect(betaTxResult2, Test.beSucceeded()) + grantBetaPoolParticipantAccess(protocolAccount, liquidityAccount) + grantBetaPoolParticipantAccess(protocolAccount, hackerAccount) setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: 0.0001) setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: moetTokenIdentifier, price: 1.0) @@ -47,7 +43,7 @@ fun setup() { setupMoetVault(hackerAccount, beFailed: false) // provide liquidity to the pool we can extract - createWrappedPosition(signer: liquidityAccount, amount: 10000.0, vaultStoragePath: flowVaultStoragePath, pushToDrawDownSink: false) + createPosition(signer: liquidityAccount, amount: 10000.0, vaultStoragePath: flowVaultStoragePath, pushToDrawDownSink: false) } access(all) @@ -57,7 +53,7 @@ fun testMaliciousSource() { // deposit 1 Flow into the position let openRes = executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position_spoofing_source.cdc", + "./transactions/position-manager/create_position_spoofing_source.cdc", [1.0, flowVaultStoragePath, false], hackerAccount ) diff --git a/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc b/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc index 56b10f92..f1859812 100644 --- a/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc +++ b/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc @@ -10,16 +10,16 @@ transaction( pullFromTopUpSource: Bool ) { let tokenType: Type - let pool: auth(FlowCreditMarket.EParticipant, FlowCreditMarket.EPosition) &FlowCreditMarket.Pool let receiverRef: &{FungibleToken.Receiver} + let positionManager: auth(FlowCreditMarket.EPositionAdmin) &FlowCreditMarket.PositionManager prepare(signer: auth(Storage, Capabilities, BorrowValue) &Account) { self.tokenType = CompositeType(tokenTypeIdentifier) ?? panic("Invalid tokenTypeIdentifier: ".concat(tokenTypeIdentifier)) - // Borrow Pool with the entitlements required by withdrawAndPull - let poolCapability = MockFlowCreditMarketConsumer.getPoolCapability() - self.pool = poolCapability.borrow()! + self.positionManager = signer.storage.borrow(from: FlowCreditMarket.PositionStoragePath) + ?? panic("PositionManager not found") + // Get capability (NOT optional), then borrow a reference (optional) let cap = signer.capabilities.get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver) self.receiverRef = cap.borrow() @@ -27,8 +27,9 @@ transaction( } execute { + let position = self.positionManager.borrowAuthorizedPosition(positionID) + ?? panic("Could not borrow authorized position") let withdrawn <- self.pool.withdrawAndPull( - pid: positionID, type: self.tokenType, amount: amount, pullFromTopUpSource: pullFromTopUpSource diff --git a/cadence/tests/transactions/position-manager/create_wrapped_position_reentrancy.cdc b/cadence/tests/transactions/position-manager/create_position_reentrancy.cdc similarity index 51% rename from cadence/tests/transactions/position-manager/create_wrapped_position_reentrancy.cdc rename to cadence/tests/transactions/position-manager/create_position_reentrancy.cdc index 8c8990ed..67a89ddc 100644 --- a/cadence/tests/transactions/position-manager/create_wrapped_position_reentrancy.cdc +++ b/cadence/tests/transactions/position-manager/create_position_reentrancy.cdc @@ -6,13 +6,11 @@ import "AdversarialReentrancyConnectors" import "MOET" import "FlowToken" -import "MockFlowCreditMarketConsumer" import "FlowCreditMarket" /// TEST TRANSACTION - DO NOT USE IN PRODUCTION /// -/// Opens a FlowCreditMarket position using collateral withdrawn from the signer’s vault and -/// wraps it in a `MockFlowCreditMarketConsumer.PositionWrapper`. +/// Opens a FlowCreditMarket position using collateral withdrawn from the signer’s vault PositionWrapper. /// /// This transaction intentionally wires an **adversarial DeFiActions.Source** that attempts /// to re-enter the Pool during `withdrawAndPull` flows. It is used to validate that the Pool’s @@ -24,12 +22,17 @@ transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: B let collateral: @{FungibleToken.Vault} // this DeFiActions Sink that will receive the loaned funds let sink: {DeFiActions.Sink} - // DEBUG: this DeFiActions Source that will allow for the repayment of a loan if the position becomes undercollateralized + // this DeFiActions Source that will allow for the repayment of a loan if the position becomes undercollateralized let source: {DeFiActions.Source} - // the signer's account in which to store a PositionWrapper - let account: auth(SaveValue) &Account + // the position manager in the signer's account where we should store the new position + let positionManager: auth(FlowCreditMarket.EPositionAdmin) &FlowCreditMarket.PositionManager + // the authorized Pool capability + let poolCap: Capability + // reference to signer's account for saving capability back + let signerAccount: auth(LoadValue, BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account - prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + prepare(signer: auth(LoadValue, BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + self.signerAccount = signer // assign a Vault Capability to be used in the VaultSink let depositVaultCap = signer.capabilities.get<&{FungibleToken.Vault}>(MOET.VaultPublicPath) let withdrawVaultCap = signer.capabilities.storage.issue(/storage/flowTokenVault) @@ -62,26 +65,48 @@ transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: B liveDataCap: liveDataCap ) - // assign the signer's account enabling the execute block to save the wrapper - self.account = signer + // Get or create PositionManager at constant path + if signer.storage.borrow<&FlowCreditMarket.PositionManager>(from: FlowCreditMarket.PositionStoragePath) == nil { + // Create new PositionManager if it doesn't exist + let manager <- FlowCreditMarket.createPositionManager() + signer.storage.save(<-manager, to: FlowCreditMarket.PositionStoragePath) + + // Issue and publish capabilities for the PositionManager + let readCap = signer.capabilities.storage.issue<&FlowCreditMarket.PositionManager>(FlowCreditMarket.PositionStoragePath) + + // Publish read-only capability publicly + signer.capabilities.publish(readCap, at: FlowCreditMarket.PositionPublicPath) + } + self.positionManager = signer.storage.borrow(from: FlowCreditMarket.PositionStoragePath) + ?? panic("PositionManager not found") + + // Load the authorized Pool capability from storage + self.poolCap = signer.storage.load>( + from: FlowCreditMarket.PoolCapStoragePath + ) ?? panic("Could not load Pool capability from storage - ensure the signer has been granted Pool access with EParticipant entitlement") } execute { - // open a position & save in the Wrapper - let wrapper <- MockFlowCreditMarketConsumer.createPositionWrapper( - collateral: <-self.collateral, + // Borrow the authorized Pool reference + let poolRef = self.poolCap.borrow() ?? panic("Could not borrow Pool capability") + + // Create position + let position <- poolRef.createPosition( + funds: <-self.collateral, issuanceSink: self.sink, repaymentSource: self.source, pushToDrawDownSink: pushToDrawDownSink ) - let poolCapability = MockFlowCreditMarketConsumer.getPoolCapability() - log("Pool capability: \(poolCapability.check())") + + let pid = position.id + + self.positionManager.addPosition(position: <-position) let sourceRef = self.source as! AdversarialReentrancyConnectors.VaultSourceHacked let liveData = sourceRef.liveDataCap.borrow() ?? panic("cant borrow LiveData") - liveData.setRecursivePool(poolCapability) - liveData.setRecursivePositionID(wrapper.positionID) + liveData.setRecursivePool(self.poolCap) + liveData.setRecursivePositionID(pid) - self.account.storage.save(<-wrapper, to: MockFlowCreditMarketConsumer.WrapperStoragePath) + self.signerAccount.storage.save(self.poolCap, to: FlowCreditMarket.PoolCapStoragePath) } } diff --git a/cadence/tests/transactions/position-manager/create_wrapped_position_spoofing_source.cdc b/cadence/tests/transactions/position-manager/create_position_spoofing_source.cdc similarity index 52% rename from cadence/tests/transactions/position-manager/create_wrapped_position_spoofing_source.cdc rename to cadence/tests/transactions/position-manager/create_position_spoofing_source.cdc index efb7bb9b..e9647c0a 100644 --- a/cadence/tests/transactions/position-manager/create_wrapped_position_spoofing_source.cdc +++ b/cadence/tests/transactions/position-manager/create_position_spoofing_source.cdc @@ -6,13 +6,11 @@ import "AdversarialTypeSpoofingConnectors" import "MOET" import "FlowToken" -import "MockFlowCreditMarketConsumer" import "FlowCreditMarket" /// TEST TRANSACTION - DO NOT USE IN PRODUCTION /// -/// Opens a Position with the amount of funds source from the Vault at the provided StoragePath and wraps it in a -/// MockFlowCreditMarketConsumer PositionWrapper +/// Opens a Position with the amount of funds source from the Vault at the provided StoragePath and puts it a PositionManager /// /// This transaction intentionally wires an **adversarial DeFiActions.Source** that attempts /// to **spoof token types** during withdrawal. It is used to verify that the Pool and its @@ -25,12 +23,17 @@ transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: B let collateral: @{FungibleToken.Vault} // this DeFiActions Sink that will receive the loaned funds let sink: {DeFiActions.Sink} - // DEBUG: this DeFiActions Source that will allow for the repayment of a loan if the position becomes undercollateralized + // this DeFiActions Source that will allow for the repayment of a loan if the position becomes undercollateralized let source: {DeFiActions.Source} - // the signer's account in which to store a PositionWrapper - let account: auth(SaveValue) &Account + // the position manager in the signer's account where we should store the new position + let positionManager: auth(FlowCreditMarket.EPositionAdmin) &FlowCreditMarket.PositionManager + // the authorized Pool capability + let poolCap: Capability + // reference to signer's account for saving capability back + let signerAccount: auth(LoadValue,BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account - prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + prepare(signer: auth(LoadValue,BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + self.signerAccount = signer // configure a MOET Vault to receive the loaned amount if signer.storage.type(at: MOET.VaultStoragePath) == nil { // save a new MOET Vault @@ -62,19 +65,44 @@ transaction(amount: UFix64, vaultStoragePath: StoragePath, pushToDrawDownSink: B self.source = AdversarialTypeSpoofingConnectors.VaultSourceFakeType( withdrawVault: withdrawVaultCap, ) - // assign the signer's account enabling the execute block to save the wrapper - self.account = signer + // Get or create PositionManager at constant path + if signer.storage.borrow<&FlowCreditMarket.PositionManager>(from: FlowCreditMarket.PositionStoragePath) == nil { + // Create new PositionManager if it doesn't exist + let manager <- FlowCreditMarket.createPositionManager() + signer.storage.save(<-manager, to: FlowCreditMarket.PositionStoragePath) + + // Issue and publish capabilities for the PositionManager + let readCap = signer.capabilities.storage.issue<&FlowCreditMarket.PositionManager>(FlowCreditMarket.PositionStoragePath) + + // Publish read-only capability publicly + signer.capabilities.publish(readCap, at: FlowCreditMarket.PositionPublicPath) + } + self.positionManager = signer.storage.borrow(from: FlowCreditMarket.PositionStoragePath) + ?? panic("PositionManager not found") + + // Load the authorized Pool capability from storage + self.poolCap = signer.storage.load>( + from: FlowCreditMarket.PoolCapStoragePath + ) ?? panic("Could not load Pool capability from storage - ensure the signer has been granted Pool access with EParticipant entitlement") } execute { - // open a position & save in the Wrapper - let wrapper <- MockFlowCreditMarketConsumer.createPositionWrapper( - collateral: <-self.collateral, + // Borrow the authorized Pool reference + let poolRef = self.poolCap.borrow() ?? panic("Could not borrow Pool capability") + + // Create position + let position <- poolRef.createPosition( + funds: <-self.collateral, issuanceSink: self.sink, repaymentSource: self.source, pushToDrawDownSink: pushToDrawDownSink ) - // save the wrapper into the signer's account - reverts on storage collision - self.account.storage.save(<-wrapper, to: MockFlowCreditMarketConsumer.WrapperStoragePath) + + let pid = position.id + + self.positionManager.addPosition(position: <-position) + + // Save the capability back to storage for future use + self.signerAccount.storage.save(self.poolCap, to: FlowCreditMarket.PoolCapStoragePath) } } From 95f517c21620990c8e7ddde99418428281aa3a70 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:10:47 -0500 Subject: [PATCH 16/18] fix tests --- cadence/contracts/FlowCreditMarket.cdc | 7 ++++++- cadence/tests/async_update_position_test.cdc | 8 ++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 7cd6a224..437f3e5d 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -3546,7 +3546,12 @@ access(all) contract FlowCreditMarket { self._depositEffectsOnly(pid: pid, from: <-depositVault) // We need to update the queued vault to reflect the amount we used up - position.queuedDeposits[depositType] <-! queuedVault + if let existing <- position.queuedDeposits.remove(key: depositType) { + existing.deposit(from: <-queuedVault) + position.queuedDeposits[depositType] <-! existing + } else { + position.queuedDeposits[depositType] <-! queuedVault + } } } diff --git a/cadence/tests/async_update_position_test.cdc b/cadence/tests/async_update_position_test.cdc index 78b48b40..9ef9be4e 100644 --- a/cadence/tests/async_update_position_test.cdc +++ b/cadence/tests/async_update_position_test.cdc @@ -10,10 +10,6 @@ access(all) fun setup() { deployContracts() - let betaTxResult = grantBeta(PROTOCOL_ACCOUNT, CONSUMER_ACCOUNT) - - Test.expect(betaTxResult, Test.beSucceeded()) - snapshot = getCurrentBlockHeight() } @@ -38,12 +34,12 @@ fun testUpdatePosition() { setupMoetVault(user, beFailed: false) mintFlow(to: user, amount: 1_000.0) - createWrappedPosition(signer: user, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + createPosition(signer: user, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) // increase price setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice * priceIncreaseFactor) - depositToWrappedPosition(signer: user, amount: 600.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + depositToPosition(signer: user, positionID: 0, amount: 600.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) let updatePositionRes = _executeTransaction( "./transactions/flow-credit-market/pool-management/async_update_position.cdc", From c3c9ce5e1973ce8034ef97e40719ceea065cdac3 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:20:48 -0500 Subject: [PATCH 17/18] post check unlock position --- cadence/contracts/FlowCreditMarket.cdc | 27 +++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 437f3e5d..189adcae 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1600,9 +1600,6 @@ access(all) contract FlowCreditMarket { } access(self) fun _unlockPosition(_ pid: UInt64) { - post { - self.positionLock[pid] == nil: "Position is not unlocked" - } // Always unlock (even if missing) self.positionLock.remove(key: pid) } @@ -1928,6 +1925,9 @@ access(all) contract FlowCreditMarket { debtType == repayment.getType(): "Repayment vault does not match debt type: \(debtType.identifier)!=\(repayment.getType().identifier)" // TODO(jord): liquidation paused / post-pause warm } + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } self._lockPosition(pid) @@ -2637,6 +2637,9 @@ access(all) contract FlowCreditMarket { "Invalid token type \(funds.getType().identifier) - not supported by this Pool" // TODO(jord): Sink/source should be valid } + post { + self.positionLock[self.nextPositionID] == nil: "Position is not unlocked" + } // construct a new InternalPosition, assigning it the current position ID let id = self.nextPositionID self.nextPositionID = self.nextPositionID + 1 @@ -2805,6 +2808,9 @@ access(all) contract FlowCreditMarket { self.globalLedger[from.getType()] != nil: "Invalid token type \(from.getType().identifier) - not supported by this Pool" } + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } if self.debugLogging { log(" [CONTRACT] depositAndPush(pid: \(pid), pushToDrawDownSink: \(pushToDrawDownSink))") } @@ -2854,6 +2860,9 @@ access(all) contract FlowCreditMarket { self.globalLedger[type] != nil: "Invalid token type \(type.identifier) - not supported by this Pool" } + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } self._lockPosition(pid) if self.debugLogging { log(" [CONTRACT] withdrawAndPull(pid: \(pid), type: \(type.identifier), amount: \(amount), pullFromTopUpSource: \(pullFromTopUpSource))") @@ -2985,6 +2994,9 @@ 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}?) { + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } self._lockPosition(pid) let position = self._borrowPosition(pid: pid) position.setDrawDownSink(sink) @@ -2995,6 +3007,9 @@ access(all) contract FlowCreditMarket { /// 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}?) { + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } self._lockPosition(pid) let position = self._borrowPosition(pid: pid) position.setTopUpSource(source) @@ -3391,6 +3406,9 @@ access(all) contract FlowCreditMarket { /// of either cannot accept/provide sufficient funds for rebalancing, the rebalance will still occur but will /// not cause the position to reach its target health. access(EPosition) fun rebalancePosition(pid: UInt64, force: Bool) { + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } self._lockPosition(pid) self._rebalancePositionNoLock(pid: pid, force: force) self._unlockPosition(pid) @@ -3523,6 +3541,9 @@ access(all) contract FlowCreditMarket { /// Executes an asynchronous update on the specified position access(EImplementation) fun asyncUpdatePosition(pid: UInt64) { + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } self._lockPosition(pid) let position = self._borrowPosition(pid: pid) From b7bf0e6ccdd713699a56b075c5c8d4e93cfe85bb Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:25:19 -0500 Subject: [PATCH 18/18] address PR comments --- cadence/contracts/FlowCreditMarket.cdc | 11 ++- .../mocks/MockFlowCreditMarketConsumer.cdc | 91 ------------------- .../withdraw_from_position.cdc | 1 - 3 files changed, 9 insertions(+), 94 deletions(-) delete mode 100644 cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 189adcae..122a8cf6 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1540,7 +1540,12 @@ access(all) contract FlowCreditMarket { /// TODO: unused! To remove, must re-deploy existing contracts access(self) var dexMaxRouteHops: UInt64 - // Reentrancy guards keyed by position id + /// Reentrancy guards keyed by position id. + /// When a position is locked, it means an operation on the position is in progress. + /// While a position is locked, no new operation can begin on the locked position. + /// All positions must be unlocked at the end of each transaction. + /// A locked position is indicated by the presence of an entry {pid: True} in the map. + /// An unlocked position is indicated by the lack of entry for the pid in the map. access(self) var positionLock: {UInt64: Bool} init( @@ -1592,6 +1597,7 @@ access(all) contract FlowCreditMarket { // Vaults will be created when tokens are first deposited. } + /// Marks the position as locked. Panics if the position is already locked. access(self) fun _lockPosition(_ pid: UInt64) { // If key absent => unlocked let locked = self.positionLock[pid] ?? false @@ -1599,6 +1605,7 @@ access(all) contract FlowCreditMarket { self.positionLock[pid] = true } + /// Marks the position as unlocked. No-op if the position is already unlocked. access(self) fun _unlockPosition(_ pid: UInt64) { // Always unlock (even if missing) self.positionLock.remove(key: pid) @@ -2638,7 +2645,7 @@ access(all) contract FlowCreditMarket { // TODO(jord): Sink/source should be valid } post { - self.positionLock[self.nextPositionID] == nil: "Position is not unlocked" + self.positionLock[result.id] == nil: "Position is not unlocked" } // construct a new InternalPosition, assigning it the current position ID let id = self.nextPositionID diff --git a/cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc b/cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc deleted file mode 100644 index 90bb16fa..00000000 --- a/cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc +++ /dev/null @@ -1,91 +0,0 @@ -import "FungibleToken" - -import "DeFiActions" -import "FlowCreditMarket" - -/// THIS CONTRACT IS NOT SAFE FOR PRODUCTION - FOR TEST USE ONLY -/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -/// -/// A simple contract enabling the persistent storage of a Position similar to a pattern expected for platforms -/// building on top of FlowCreditMarket's lending protocol -/// -access(all) contract MockFlowCreditMarketConsumer { - - /// Canonical path for where the wrapper is to be stored - access(all) let WrapperStoragePath: StoragePath - - /// Opens a FlowCreditMarket Position and returns a PositionWrapper containing that new position - /// - access(all) - fun createPositionWrapper( - collateral: @{FungibleToken.Vault}, - issuanceSink: {DeFiActions.Sink}, - repaymentSource: {DeFiActions.Source}?, - pushToDrawDownSink: Bool - ): @PositionWrapper { - let poolCap = self.account.storage.load>( - from: FlowCreditMarket.PoolCapStoragePath - ) ?? panic("Missing pool capability") - - let poolRef = poolCap.borrow() ?? panic("Invalid Pool Cap") - - let pid = poolRef.createPosition( - funds: <-collateral, - issuanceSink: issuanceSink, - repaymentSource: repaymentSource, - pushToDrawDownSink: pushToDrawDownSink - ) - let position = FlowCreditMarket.Position(id: pid, pool: poolCap) - self.account.storage.save(poolCap, to: FlowCreditMarket.PoolCapStoragePath) - return <- create PositionWrapper( - pid: pid, - position: position - ) - } - - access(all) - fun getPoolCapability(): - Capability { - - let poolCapCopy = self.account.storage.copy< - Capability - >(from: FlowCreditMarket.PoolCapStoragePath) - ?? panic("Missing pool capability") - - return poolCapCopy - } - - /// A simple resource encapsulating a FlowCreditMarket Position - access(all) resource PositionWrapper { - - access(self) let position: FlowCreditMarket.Position - access(all) let positionID: UInt64 - - init(pid: UInt64, position: FlowCreditMarket.Position) { - self.positionID = pid - self.position = position - } - - /// NOT SAFE FOR PRODUCTION - /// - /// Returns a reference to the wrapped Position - access(all) fun borrowPosition(): &FlowCreditMarket.Position { - return &self.position - } - - /// NOT SAFE FOR PRODUCTION - /// - /// Returns a reference to the wrapped Position with EParticipant entitlement for deposits - access(all) fun borrowPositionForDeposit(): auth(FlowCreditMarket.EParticipant) &FlowCreditMarket.Position { - return &self.position - } - - access(all) fun borrowPositionForWithdraw(): auth(FungibleToken.Withdraw) &FlowCreditMarket.Position { - return &self.position - } - } - - init() { - self.WrapperStoragePath = /storage/flowCreditMarketPositionWrapper - } -} diff --git a/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc b/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc index f1859812..2e9b3f0e 100644 --- a/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc +++ b/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc @@ -1,6 +1,5 @@ import "FlowCreditMarket" import "FungibleToken" -import "MockFlowCreditMarketConsumer" /// Withdraw assets from an existing credit position, depositing to signer's Receiver transaction(