diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index a45244d5..122a8cf6 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1540,6 +1540,14 @@ access(all) contract FlowCreditMarket { /// TODO: unused! To remove, must re-deploy existing contracts access(self) var dexMaxRouteHops: UInt64 + /// 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( defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}, @@ -1583,10 +1591,26 @@ 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. } + /// 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 + assert(!locked, message: "Reentrancy: position \(pid) is locked") + 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) + } + access(self) fun _assertLiquidationsActive() { pre { !self.liquidationsPaused: @@ -1908,6 +1932,11 @@ 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) let positionView = self.buildPositionView(pid: pid) let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) @@ -1960,7 +1989,11 @@ access(all) contract FlowCreditMarket { assert(Pcd_dex_oracle_diffBps <= self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") // Execute the liquidation - return <- self._doLiquidation(pid: pid, repayment: <-repayment, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) + let seizedCollateral <- self._doLiquidation(pid: pid, repayment: <-repayment, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) + + self._unlockPosition(pid) + + return <- seizedCollateral } /// Gets a swapper from the DEX for the given token pair. @@ -2611,11 +2644,16 @@ 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[result.id] == nil: "Position is not unlocked" + } // construct a new InternalPosition, assigning it the current position ID let id = self.nextPositionID self.nextPositionID = self.nextPositionID + 1 self.positions[id] <-! create InternalPosition() + self._lockPosition(id) + emit Opened( pid: id, poolUUID: self.uuid @@ -2630,11 +2668,12 @@ access(all) contract FlowCreditMarket { } // deposit the initial funds - self.depositAndPush( - pid: id, - from: <-funds, - pushToDrawDownSink: pushToDrawDownSink - ) + self._depositEffectsOnly(pid: id, from: <-funds) + + // Rebalancing and queue management + if pushToDrawDownSink { + self._rebalancePositionNoLock(pid: id, force: true) + } // Create a capability to the Pool for the Position resource // The Pool is stored in the FlowCreditMarket contract account @@ -2643,7 +2682,11 @@ access(all) contract FlowCreditMarket { ) // Create and return the Position resource - return <- create Position(id: id, pool: poolCap) + + let position <- create Position(id: id, pool: poolCap) + + self._unlockPosition(id) + return <-position } /// Allows anyone to deposit funds into any position. @@ -2655,37 +2698,29 @@ access(all) contract FlowCreditMarket { pushToDrawDownSink: false ) } - - /// 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`. + /// 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(EPosition) fun depositAndPush( + access(self) fun _depositEffectsOnly( pid: UInt64, - from: @{FungibleToken.Vault}, - pushToDrawDownSink: Bool + from: @{FungibleToken.Vault} ) { - pre { - self.positions[pid] != nil: - "Invalid position ID \(pid) - could not find an InternalPosition with the requested ID in the Pool" - self.globalLedger[from.getType()] != nil: - "Invalid token type \(from.getType().identifier) - not supported by this Pool" - } - if self.debugLogging { - log(" [CONTRACT] depositAndPush(pid: \(pid), pushToDrawDownSink: \(pushToDrawDownSink))") - } - - if from.balance == 0.0 { + // NOTE: caller must have already validated pid + token support + let amount = from.balance + if amount == 0.0 { Burner.burn(<-from) return } // Get a reference to the user's position and global token state for the affected token. let type = from.getType() + let depositedUUID = from.uuid let position = self._borrowPosition(pid: pid) let tokenState = self._borrowUpdatedTokenState(type: type) - let amount = from.balance - let depositedUUID = from.uuid // Time-based state is handled by the tokenState() helper function @@ -2754,12 +2789,8 @@ access(all) contract FlowCreditMarket { // Add the money to the reserves reserveVault.deposit(from: <-from) - // Rebalancing and queue management - if pushToDrawDownSink { - self.rebalancePosition(pid: pid, force: true) - } - self._queuePositionForUpdateIfNecessary(pid: pid) + emit Deposited( pid: pid, poolUUID: self.uuid, @@ -2767,6 +2798,40 @@ access(all) contract FlowCreditMarket { amount: amount, depositedUUID: depositedUUID ) + + } + + /// Deposits the provided funds to the specified position with the configurable `pushToDrawDownSink` option. + /// If `pushToDrawDownSink` is true, excess value putting the position above its max health + /// is pushed to the position's configured `drawDownSink`. + access(EPosition) fun depositAndPush( + pid: UInt64, + from: @{FungibleToken.Vault}, + pushToDrawDownSink: Bool + ) { + pre { + self.positions[pid] != nil: + "Invalid position ID \(pid) - could not find an InternalPosition with the requested ID in the Pool" + self.globalLedger[from.getType()] != nil: + "Invalid token type \(from.getType().identifier) - not supported by this Pool" + } + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } + if self.debugLogging { + log(" [CONTRACT] depositAndPush(pid: \(pid), pushToDrawDownSink: \(pushToDrawDownSink))") + } + + self._lockPosition(pid) + + self._depositEffectsOnly(pid: pid, from: <-from) + + // Rebalancing and queue management + if pushToDrawDownSink { + self._rebalancePositionNoLock(pid: pid, force: true) + } + + self._unlockPosition(pid) } /// Withdraws the requested funds from the specified position. @@ -2802,10 +2867,15 @@ 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))") } if amount == 0.0 { + self._unlockPosition(pid) return <- DeFiActionsUtils.getEmptyVault(type) } @@ -2828,7 +2898,6 @@ access(all) contract FlowCreditMarket { ) var canWithdraw = false - var usedTopUp = false if requiredDeposit == 0.0 { // We can service this withdrawal without any top up @@ -2846,28 +2915,26 @@ access(all) contract FlowCreditMarket { ) let pulledVault <- topUpSource.withdrawAvailable(maxAmount: idealDeposit) + assert(pulledVault.getType() == topUpType, message: "topUpSource returned unexpected token type") let pulledAmount = pulledVault.balance + // NOTE: We requested the "ideal" deposit, but we compare against the required deposit here. // The top up source may not have enough funds get us to the target health, but could have // enough to keep us over the minimum. if pulledAmount >= requiredDeposit { // We can service this withdrawal if we deposit funds from our top up source - self.depositAndPush( + self._depositEffectsOnly( pid: pid, - from: <-pulledVault, - pushToDrawDownSink: false + from: <-pulledVault ) - usedTopUp = pulledAmount > 0.0 canWithdraw = true } else { // We can't get the funds required to service this withdrawal, so we need to redeposit what we got - self.depositAndPush( + self._depositEffectsOnly( pid: pid, - from: <-pulledVault, - pushToDrawDownSink: false + from: <-pulledVault ) - usedTopUp = pulledAmount > 0.0 } } } @@ -2884,7 +2951,6 @@ access(all) contract FlowCreditMarket { log(" [CONTRACT] Required deposit for minHealth: \(requiredDeposit)") log(" [CONTRACT] Pull from topUpSource: \(pullFromTopUpSource)") } - // 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") } @@ -2905,16 +2971,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: "Post-withdrawal position health (\(postHealth)) is below min health threshold (\(position.minHealth))" + ) // Queue for update if necessary self._queuePositionForUpdateIfNecessary(pid: pid) @@ -2929,6 +2993,7 @@ access(all) contract FlowCreditMarket { withdrawnUUID: withdrawn.uuid ) + self._unlockPosition(pid) return <- withdrawn } @@ -2936,16 +3001,26 @@ 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) + 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}?) { + post { + self.positionLock[pid] == nil: "Position is not unlocked" + } + self._lockPosition(pid) let position = self._borrowPosition(pid: pid) position.setTopUpSource(source) + self._unlockPosition(pid) } // ---- Position health accessors (called via Position using EPosition capability) ---- @@ -3338,6 +3413,22 @@ 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) + } + + /// 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))") } @@ -3363,7 +3454,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, @@ -3373,10 +3466,9 @@ access(all) contract FlowCreditMarket { fromUnder: true ) - self.depositAndPush( + self._depositEffectsOnly( pid: pid, from: <-pulledVault, - pushToDrawDownSink: false ) } } else if balanceSheet.health > position.targetHealth { @@ -3426,10 +3518,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) @@ -3437,6 +3528,7 @@ access(all) contract FlowCreditMarket { } } } + } /// Executes asynchronous updates on positions that have been queued up to the lesser of the queue length or @@ -3456,10 +3548,16 @@ 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) + // 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) @@ -3467,29 +3565,28 @@ 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 + if let existing <- position.queuedDeposits.remove(key: depositType) { + existing.deposit(from: <-queuedVault) + position.queuedDeposits[depositType] <-! existing + } else { + position.queuedDeposits[depositType] <-! queuedVault + } } } // 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) } /// Updates interest rates for a token and collects stability fee. 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..6ad4d1e6 --- /dev/null +++ b/cadence/tests/adversarial_recursive_withdraw_source_test.cdc @@ -0,0 +1,133 @@ +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 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() + + grantBetaPoolParticipantAccess(protocolAccount, protocolConsumerAccount) + grantBetaPoolParticipantAccess(protocolAccount, userAccount) + + // 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() { + // 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) + } + + // ------------------------------------------------------------------------- + // 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) + + let initialDeposit1 = 10000.0 + createPosition( + signer: user1, + amount: initialDeposit1, + vaultStoragePath: /storage/flowTokenVault, + pushToDrawDownSink: false + ) + log("[TEST] USER1 POSITION ID: \(positionID)") + + // ------------------------------------------------------------------------- + // 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/position-manager/create_position_reentrancy.cdc", + [positionFundingAmount, flowVaultStoragePath, false], + userAccount + ) + Test.expect(openRes, Test.beSucceeded()) + + // 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] User FLOW balance after open: \(remainingFlow)") + let moetBalance = getBalance(address: userAccount.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + 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 + userAccount + ) + 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] User FLOW balance after failed withdraw: \(currentFlow)") + let currentMoet = getBalance(address: userAccount.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + 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 new file mode 100644 index 00000000..a58825c8 --- /dev/null +++ b/cadence/tests/adversarial_type_spoofing_test.cdc @@ -0,0 +1,73 @@ +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 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) +fun setup() { + deployContracts() + + grantBetaPoolParticipantAccess(protocolAccount, liquidityAccount) + grantBetaPoolParticipantAccess(protocolAccount, hackerAccount) + + 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 + createPosition(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/position-manager/create_position_spoofing_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/async_update_position_test.cdc b/cadence/tests/async_update_position_test.cdc new file mode 100644 index 00000000..9ef9be4e --- /dev/null +++ b/cadence/tests/async_update_position_test.cdc @@ -0,0 +1,50 @@ +import Test +import BlockchainHelpers +import "FlowCreditMarket" + +import "test_helpers.cdc" + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun setup() { + deployContracts() + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun testUpdatePosition() { + let initialPrice = 1.0 + 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) + + 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) + + 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) + + 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", + [ 0 as UInt64 ], + PROTOCOL_ACCOUNT + ) + Test.expect(updatePositionRes, Test.beSucceeded()) +} diff --git a/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc b/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc new file mode 100644 index 00000000..ac736f40 --- /dev/null +++ b/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc @@ -0,0 +1,236 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" + +import "DeFiActionsUtils" +import "DeFiActions" +import "FlowCreditMarket" + +import "MOET" +import "FlowToken" + +/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +/// THIS IS A TESTING CONTRACT THAT SHOULD NOT BE USED IN PRODUCTION +/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +/// +/// 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 + /// + /// 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 + + 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..ca075c09 --- /dev/null +++ b/cadence/tests/contracts/AdversarialTypeSpoofingConnectors.cdc @@ -0,0 +1,61 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" + +import "DeFiActionsUtils" +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 { + 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 826645aa..435ef597 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -141,6 +141,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/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) + } +} 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..2e9b3f0e --- /dev/null +++ b/cadence/tests/transactions/flow-credit-market/pool-management/withdraw_from_position.cdc @@ -0,0 +1,39 @@ +import "FlowCreditMarket" +import "FungibleToken" + +/// 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 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)) + + 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() + ?? panic("Could not borrow receiver ref from /public/flowTokenReceiver") + } + + execute { + let position = self.positionManager.borrowAuthorizedPosition(positionID) + ?? panic("Could not borrow authorized position") + let withdrawn <- self.pool.withdrawAndPull( + type: self.tokenType, + amount: amount, + pullFromTopUpSource: pullFromTopUpSource + ) + + self.receiverRef.deposit(from: <-withdrawn) + } +} diff --git a/cadence/tests/transactions/position-manager/create_position_reentrancy.cdc b/cadence/tests/transactions/position-manager/create_position_reentrancy.cdc new file mode 100644 index 00000000..67a89ddc --- /dev/null +++ b/cadence/tests/transactions/position-manager/create_position_reentrancy.cdc @@ -0,0 +1,112 @@ +import "FungibleToken" + +import "DeFiActions" +import "FungibleTokenConnectors" +import "AdversarialReentrancyConnectors" + +import "MOET" +import "FlowToken" +import "FlowCreditMarket" + +/// TEST TRANSACTION - DO NOT USE IN PRODUCTION +/// +/// 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 +/// 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 + let collateral: @{FungibleToken.Vault} + // this DeFiActions Sink that will receive the loaned funds + let sink: {DeFiActions.Sink} + // this DeFiActions Source that will allow for the repayment of a loan if the position becomes undercollateralized + let source: {DeFiActions.Source} + // 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(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) + 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 + ) + 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 + ) + + // 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 { + // 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 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(self.poolCap) + liveData.setRecursivePositionID(pid) + + self.signerAccount.storage.save(self.poolCap, to: FlowCreditMarket.PoolCapStoragePath) + } +} diff --git a/cadence/tests/transactions/position-manager/create_position_spoofing_source.cdc b/cadence/tests/transactions/position-manager/create_position_spoofing_source.cdc new file mode 100644 index 00000000..e9647c0a --- /dev/null +++ b/cadence/tests/transactions/position-manager/create_position_spoofing_source.cdc @@ -0,0 +1,108 @@ +import "FungibleToken" + +import "DeFiActions" +import "FungibleTokenConnectors" +import "AdversarialTypeSpoofingConnectors" + +import "MOET" +import "FlowToken" +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 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 +/// 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 + let collateral: @{FungibleToken.Vault} + // this DeFiActions Sink that will receive the loaned funds + let sink: {DeFiActions.Sink} + // this DeFiActions Source that will allow for the repayment of a loan if the position becomes undercollateralized + let source: {DeFiActions.Source} + // 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(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 + 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, + ) + // 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 { + // 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 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) + } +} diff --git a/flow.json b/flow.json index 2cceccda..e6ca4252 100644 --- a/flow.json +++ b/flow.json @@ -63,6 +63,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": { @@ -247,4 +259,4 @@ ] } } -} \ No newline at end of file +}