diff --git a/cadence/tests/adversarial_recursive_withdraw_source_test.cdc b/cadence/tests/adversarial_recursive_withdraw_source_test.cdc deleted file mode 100644 index f3b8c7ed..00000000 --- a/cadence/tests/adversarial_recursive_withdraw_source_test.cdc +++ /dev/null @@ -1,134 +0,0 @@ -import Test -import BlockchainHelpers - -import "MOET" -import "FlowALPv0" -import "FlowALPEvents" -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 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: MOET_TOKEN_IDENTIFIER, price: 1.0) - - // Create the Pool & add FLOW as supported token - createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, 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( - admin: PROTOCOL_ACCOUNT, - 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! FlowALPEvents.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-alp/epositionadmin/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_reentrancy_test.cdc b/cadence/tests/adversarial_reentrancy_test.cdc new file mode 100644 index 00000000..4e9e4403 --- /dev/null +++ b/cadence/tests/adversarial_reentrancy_test.cdc @@ -0,0 +1,335 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowALPv0" +import "FlowALPEvents" +import "DeFiActions" +import "DeFiActionsUtils" +import "FlowToken" +import "FungibleToken" + +import "test_helpers.cdc" + +access(all) let protocolConsumerAccount = Test.getAccount(0x0000000000000008) +access(all) let user = Test.createAccount() + +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) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +access(all) +fun setup() { + deployContracts() + + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, protocolConsumerAccount) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: flowStartPrice) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOET_TOKEN_IDENTIFIER, price: 1.0) + + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.65, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: positionFundingAmount * 4.0) + snapshot = getCurrentBlockHeight() +} + +// ------------------------------------------------------------------------- +// 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. +access(all) +fun seedPoolLiquidity(amount: UFix64) { + let lp = Test.createAccount() + setupMoetVault(lp, beFailed: false) + mintFlow(to: lp, amount: amount) + createPosition( + admin: PROTOCOL_ACCOUNT, + signer: lp, + amount: amount, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) +} + +access(all) +fun createReentrantSourcePosition( + signer: Test.TestAccount, + vaultStoragePath: StoragePath, + amount: UFix64, + pushToDrawDownSink: Bool +): Test.TransactionResult { + return _executeTransaction( + "./transactions/position-manager/create_position_reentrant_source.cdc", + [amount, vaultStoragePath, pushToDrawDownSink], + signer + ) +} + +access(all) +fun createReentrantSinkPosition( + signer: Test.TestAccount, + amount: UFix64, + pushToDrawDownSink: Bool +): Test.TransactionResult { + return _executeTransaction( + "./transactions/position-manager/create_position_reentrant_sink.cdc", + [amount, FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink], + signer + ) +} + + +access(all) +fun test_reentrancy_recursiveWithdrawSource() { + safeReset() + + seedPoolLiquidity(amount: 10_000.0) + + // ------------------------------------------------------------------------- + // Attempt a reentrancy / recursive-withdraw scenario + // ------------------------------------------------------------------------- + // Open a new position for `user` 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 res = createReentrantSourcePosition( + signer: user, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + amount: positionFundingAmount, + pushToDrawDownSink: false + ) + Test.expect(res, Test.beSucceeded()) + + // Read the newly opened position id from the latest Opened event. + let positionID = getLastPositionId() + log("[TEST] Position opened with ID: \(positionID)") + + // Log balances for debugging context only (not assertions). + let remainingFlow = getBalance(address: user.address, vaultPublicPath: FLOW_VAULT_PUBLIC_PATH) ?? 0.0 + log("[TEST] User FLOW balance after open: \(remainingFlow)") + let moetBalance = getBalance(address: user.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/position-manager/withdraw_from_position.cdc", + [positionID, FLOW_TOKEN_IDENTIFIER, 1_500.0, true], + user + ) + Test.expect(withdrawRes, Test.beFailed()) + + // Log post-failure balances for debugging context. + let currentFlow = getBalance(address: user.address, vaultPublicPath: FLOW_VAULT_PUBLIC_PATH) ?? 0.0 + log("[TEST] User FLOW balance after failed withdraw: \(currentFlow)") + let currentMoet = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("[TEST] User MOET balance after failed withdraw: \(currentMoet)") +} + +access(all) +fun test_reentrancy_recursiveDepositSink() { + safeReset() + + seedPoolLiquidity(amount: 10_000.0) + + // ------------------------------------------------------------------------- + // Attempt a reentrancy / recursive-deposit scenario + // ------------------------------------------------------------------------- + // Open a new position for `user` using a special transaction that wires + // a *malicious* drawDownSink designed to attempt recursion during + // depositAndPush(..., pushToDrawDownSink: true). + // + // The goal is to prove the pool rejects the attempt via the position lock / + // reentrancy guard, rather than allowing nested deposit/withdraw effects. + // + // pushToDrawDownSink: false here so position creation succeeds cleanly. + // The reentrant trigger is the subsequent depositToPosition call below. + let res = createReentrantSinkPosition( + signer: user, + amount: positionFundingAmount, + pushToDrawDownSink: false + ) + Test.expect(res, Test.beSucceeded()) + + // Read the newly opened position id from the latest Opened event. + let positionID = getLastPositionId() + log("[TEST] Position opened with ID: \(positionID)") + + // Log balances for debugging context only (not assertions). + let remainingFlow = getBalance(address: user.address, vaultPublicPath: FLOW_VAULT_PUBLIC_PATH) ?? 0.0 + log("[TEST] User FLOW balance after open: \(remainingFlow)") + let moetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("[TEST] User MOET balance after open: \(moetBalance)") + + // ------------------------------------------------------------------------- + // Trigger the vulnerable path: deposit with pushToDrawDownSink=true + // ------------------------------------------------------------------------- + // The position already has 1 000 FLOW credit and no debt (health = ∞). + // Depositing an additional 500 FLOW with pushToDrawDownSink=true keeps it + // above maxHealth (1.5), so the pool will attempt to push excess value to + // the configured drawDownSink to bring health back to targetHealth (1.3). + // + // attempts to re-enter the pool during the depositCapacity call flow + // expect the transaction to fail + let depositRes = _executeTransaction( + "./transactions/position-manager/deposit_to_position.cdc", + [positionID, 500.0, FLOW_VAULT_STORAGE_PATH, true], + user + ) + Test.expect(depositRes, Test.beFailed()) + + // Log post-failure balances for debugging context. + let currentFlow = getBalance(address: user.address, vaultPublicPath: FLOW_VAULT_PUBLIC_PATH) ?? 0.0 + log("[TEST] User FLOW balance after failed deposit: \(currentFlow)") + let currentMoet = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("[TEST] User MOET balance after failed deposit: \(currentMoet)") +} + +// ============================================================================= +// Position lock is released after a failed re-entrant transaction +// +// Cadence's post-condition on withdrawAndPull: +// post { !self.state.isPositionLocked(pid): "Position is not unlocked" } +// guarantees the lock is cleared whenever the transaction does not revert. If the transaction reverts, +// all state changes are rolled back (including locking the position). +// +// maxWithdrawTokens = $100 / (CF * price) = $100 / (0.65 * $1.00) ≈ 153.8 FLOW +// 50 FLOW is safely within that limit. +// ============================================================================= +access(all) +fun test_reentrancy_guard_position_lock_released_after_failure() { + safeReset() + + seedPoolLiquidity(amount: 10_000.0) + + let res = createReentrantSourcePosition( + signer: user, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + amount: 1_000.0, + pushToDrawDownSink: true + ) + Test.expect(res, Test.beSucceeded()) + let pid = getLastPositionId() + + // trigger reentrancy — must fail + let failRes = _executeTransaction( + "./transactions/position-manager/withdraw_from_position.cdc", + [pid, FLOW_TOKEN_IDENTIFIER, 1_500.0, true], + user + ) + Test.expect(failRes, Test.beFailed()) + Test.assertError(failRes, errorMessage: "Reentrancy") + + // safe small withdrawal — must succeed, proving lock was released + withdrawFromPosition( + signer: user, + positionId: pid, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + amount: 50.0, + pullFromTopUpSource: false + ) + + // 1000 - 50 = 950 FLOW remaining + let detailsAfter = getPositionDetails(pid: pid, beFailed: false) + let flowAfter = getCreditBalanceForType( + details: detailsAfter, + vaultType: CompositeType(FLOW_TOKEN_IDENTIFIER)! + ) + Test.assertEqual(950.0, flowAfter) +} + +// ============================================================================= +// No partial writes: all state is rolled back atomically after a blocked re-entrant attempt. +// ============================================================================= +access(all) +fun test_reentrancy_state_consistency_no_partial_writes() { + safeReset() + + seedPoolLiquidity(amount: 10_000.0) + + let res = createReentrantSourcePosition( + signer: user, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + amount: 1_000.0, + pushToDrawDownSink: true + ) + Test.expect(res, Test.beSucceeded()) + let pid = getLastPositionId() + + let reserveBefore = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + + let detailsBefore = getPositionDetails(pid: pid, beFailed: false) + let creditBefore = getCreditBalanceForType( + details: detailsBefore, + vaultType: CompositeType(FLOW_TOKEN_IDENTIFIER)! + ) + let moetDebtBefore = getDebitBalanceForType( + details: detailsBefore, + vaultType: Type<@MOET.Vault>() + ) + let FLOWBefore = getBalance( + address: user.address, + vaultPublicPath: FLOW_VAULT_PUBLIC_PATH + ) ?? 0.0 + + // trigger re-entrant failure + let failRes = _executeTransaction( + "./transactions/position-manager/withdraw_from_position.cdc", + [pid, FLOW_TOKEN_IDENTIFIER, 1_500.0, true], + user + ) + Test.expect(failRes, Test.beFailed()) + Test.assertEqual(reserveBefore, getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER)) + + let detailsAfter = getPositionDetails(pid: pid, beFailed: false) + let creditAfter = getCreditBalanceForType( + details: detailsAfter, + vaultType: CompositeType(FLOW_TOKEN_IDENTIFIER)! + ) + Test.assertEqual(creditBefore, creditAfter) + + let moetDebtAfter = getDebitBalanceForType( + details: detailsAfter, + vaultType: Type<@MOET.Vault>() + ) + Test.assertEqual(moetDebtBefore, moetDebtAfter) + + let FLOWAfter = getBalance( + address: user.address, + vaultPublicPath: FLOW_VAULT_PUBLIC_PATH + ) ?? 0.0 + Test.assertEqual(FLOWBefore, FLOWAfter) +} diff --git a/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc b/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc index 054761b5..96150967 100644 --- a/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc +++ b/cadence/tests/contracts/AdversarialReentrancyConnectors.cdc @@ -16,17 +16,33 @@ import "FlowToken" /// /// 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. +/// This contract holds malicious DeFi connectors which implement re-entrancy attacks. +/// +/// VaultSourceHacked — malicious topUpSource. +/// When the pool calls withdrawAvailable() during withdrawAndPull, the source +/// immediately calls position.withdraw() on the same pid while the position +/// lock is already held. This tests that the reentrancy guard blocks the +/// inner call and reverts the entire transaction. +/// +/// VaultSinkHacked — malicious drawDownSink. +/// When the pool calls depositCapacity() during a rebalance/drawdown push, +/// the sink immediately calls position.depositAndPush() on the same pid +/// while the position lock is already held. This tests that the reentrancy +/// guard blocks the inner call and reverts the entire transaction. +/// +/// Both connectors share the LiveData resource to store the manager cap and +/// pid needed for the re-entrant call. access(all) contract AdversarialReentrancyConnectors { - /// VaultSink - /// - /// A DeFiActions connector that deposits tokens into a Vault - /// + // ========================================================================= + // VaultSinkHacked — malicious DeFiActions.Sink + // + // When depositCapacity() is called by the pool during a rebalance / + // drawdown push, this sink attempts a re-entrant position.depositAndPush() + // on the same pid while the position lock is held. + // Expected result: lockPosition panics with "Reentrancy: position X is locked" + // and the entire outer transaction reverts. + // ========================================================================= access(all) struct VaultSinkHacked : DeFiActions.Sink { /// The Vault Type accepted by the Sink access(all) let depositVaultType: Type @@ -37,11 +53,13 @@ access(all) contract AdversarialReentrancyConnectors { access(contract) var uniqueID: DeFiActions.UniqueIdentifier? /// An unentitled Capability on the Vault to which deposits are distributed access(self) let depositVault: Capability<&{FungibleToken.Vault}> + access(all) let liveDataCap: Capability<&LiveData> init( max: UFix64?, depositVault: Capability<&{FungibleToken.Vault}>, - uniqueID: DeFiActions.UniqueIdentifier? + uniqueID: DeFiActions.UniqueIdentifier?, + liveDataCap: Capability<&LiveData> ) { pre { depositVault.check(): "Provided invalid Capability" @@ -54,6 +72,7 @@ access(all) contract AdversarialReentrancyConnectors { self.uniqueID = uniqueID self.depositVaultType = depositVault.borrow()!.getType() self.depositVault = depositVault + self.liveDataCap = liveDataCap } /// Returns a ComponentInfo struct containing information about this VaultSink and its inner DFA components @@ -97,6 +116,25 @@ access(all) contract AdversarialReentrancyConnectors { } /// Deposits up to the Sink's capacity from the provided Vault access(all) fun depositCapacity(from: auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) { + log("VaultSinkHacked.depositCapacity called with balance: \(from.balance)") + log("liveDataCap valid: \(self.liveDataCap.check())") + + let liveData = self.liveDataCap.borrow() ?? panic("cant borrow LiveData") + let manager = liveData.positionManagerCap!.borrow() ?? panic("cant borrow PositionManager") + let position = manager.borrowAuthorizedPosition(pid: liveData.recursivePositionID!) + + // Attempt re-entrant deposit via Position — must fail due to position lock. + // We create a small empty MOET vault as the re-entrant deposit payload. + // The point is not the vault contents but the call itself hitting the lock. + let reentrantVault <- MOET.createEmptyVault(vaultType: Type<@MOET.Vault>()) + log("Attempting re-entrant depositAndPush (should not succeed)") + position.depositAndPush( + from: <-reentrantVault, + pushToDrawDownSink: false + ) + log("Re-entrant depositAndPush succeeded (should not reach here)") + + // Normal sink behaviour let minimumCapacity = self.minimumCapacity() if !self.depositVault.check() || minimumCapacity == 0.0 { return @@ -107,13 +145,21 @@ access(all) contract AdversarialReentrancyConnectors { } } + // ========================================================================= + // LiveData — shared mutable resource used by both hacked connectors. + // Stores the PositionManager capability and target pid, injected after + // position creation via setRecursivePosition(). + // ========================================================================= access(all) resource LiveData { - /// Capability to the attacker's PositionManager for recursive withdrawal + /// Capability to the attacker's PositionManager for the recursive call access(all) var positionManagerCap: Capability? - /// Position ID for recursive withdrawal + /// Position ID targeted by the recursive call access(all) var recursivePositionID: UInt64? - init() { self.recursivePositionID = nil; self.positionManagerCap = nil } + init() { + self.recursivePositionID = nil + self.positionManagerCap = nil + } access(all) fun setRecursivePosition( managerCap: Capability, pid: UInt64 @@ -126,10 +172,15 @@ access(all) contract AdversarialReentrancyConnectors { return <- create LiveData() } - /// VaultSource - /// - /// A DeFiActions connector that withdraws tokens from a Vault - /// + // ========================================================================= + // VaultSourceHacked — malicious DeFiActions.Source + // + // When withdrawAvailable() is called by the pool during withdrawAndPull, + // this source attempts a re-entrant position.withdraw() on the same pid + // while the position lock is held. + // Expected result: lockPosition panics with "Reentrancy: position X is locked" + // and the entire outer transaction reverts. + // ========================================================================= access(all) struct VaultSourceHacked : DeFiActions.Source { /// Returns the Vault type provided by this Source access(all) let withdrawVaultType: Type @@ -204,7 +255,7 @@ access(all) contract AdversarialReentrancyConnectors { /// 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("VaultSourceHacked.withdrawAvailable called with maxAmount: \(maxAmount)") log("=====Recursive position manager: \(self.liveDataCap.check())") let liveData = self.liveDataCap.borrow() ?? panic("cant borrow LiveData") let manager = liveData.positionManagerCap!.borrow() ?? panic("cant borrow PositionManager") @@ -223,7 +274,7 @@ access(all) contract AdversarialReentrancyConnectors { panic("Withdraw vault check failed") } // take the lesser between the available and maximum requested amount - let withdrawalAmount = available <= maxAmount ? available : maxAmount; + let withdrawalAmount = available <= maxAmount ? available : maxAmount return <- self.withdrawVault.borrow()!.withdraw(amount: withdrawalAmount) } } diff --git a/cadence/tests/insurance_swapper_test.cdc b/cadence/tests/insurance_swapper_test.cdc index fdd70637..f629b966 100644 --- a/cadence/tests/insurance_swapper_test.cdc +++ b/cadence/tests/insurance_swapper_test.cdc @@ -1,7 +1,10 @@ import Test +import BlockchainHelpers -import "test_helpers.cdc" import "FlowALPv0" +import "MOET" + +import "test_helpers.cdc" access(all) let alice = Test.createAccount() @@ -207,3 +210,91 @@ fun test_setInsuranceSwapper_wrongInputType_fails() { Test.expect(res, Test.beFailed()) Test.assertError(res, errorMessage: "Swapper input type must match token type") } + +// ----------------------------------------------------------------------------- +/// TODO: Update when automated liquidation (DEX swap execution) is implemented. +/// +/// This test is intended to validate a scenario where the swapper returns less +/// than quoted (e.g. due to slippage or malicious behavior). +/// +/// Currently, manual liquidation only uses the swapper as a price reference, +/// so this test effectively reduces to a DEX/oracle price deviation check and +/// overlaps with existing tests. +// ----------------------------------------------------------------------------- + +access(all) +fun test_insuranceSwapper_returns_less_than_quoted() { + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + let mintRes = mintFlow(to: user, amount: 1000.0) + Test.expect(mintRes, Test.beSucceeded()) + + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.65, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + createPosition( + admin: PROTOCOL_ACCOUNT, + signer: user, + amount: 1000.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: true + ) + let pid = getLastPositionId() + + // change oracle price FLOW $1.00 → $0.70 + setMockOraclePrice( + signer: PROTOCOL_ACCOUNT, + forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, + price: 0.70 + ) + + // configure the DEX swapper to return only 90 % of what it quotes + // (simulates slippage / malicious swapper). + // priceRatio here represents actual tokens-out per token-in; setting it 10 % + // below the oracle causes the inferred DEX price to diverge by ~11 %, which + // exceeds the 3 % (300 bps) threshold -> transaction must revert. + let manipulatedRatio = 0.63 // 0.70 * 0.90 = 0.63 + setMockDexPriceForPair( + signer: PROTOCOL_ACCOUNT, + inVaultIdentifier: FLOW_TOKEN_IDENTIFIER, + outVaultIdentifier: MOET_TOKEN_IDENTIFIER, + vaultSourceStoragePath: MOET.VaultStoragePath, + priceRatio: manipulatedRatio + ) + + Test.assert(getPositionHealth(pid: pid, beFailed: false) < 1.0,message: "position must be underwater before liquidation attempt") + + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: PROTOCOL_ACCOUNT, to: liquidator.address, amount: 500.0, beFailed: false) + + // DEX quote at manipulatedRatio 0.63: 50 / 0.63 ≈ 79.37 FLOW required. + // Liquidator offers 70 FLOW < 79.37 → passes better-than-DEX check. + // But DEX implied price = 0.63, oracle = 0.70: + // deviation = |0.70 - 0.63| / 0.63 ≈ 11.1 % > 3 % threshold + let liqRes = manualLiquidation( + signer: liquidator, + pid: pid, + debtVaultIdentifier: MOET_TOKEN_IDENTIFIER, + seizeVaultIdentifier: FLOW_TOKEN_IDENTIFIER, + seizeAmount: 70.0, + repayAmount: 50.0 + ) + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "DEX/oracle price deviation too large") + + // collateral must be unchanged + let details = getPositionDetails(pid: pid, beFailed: false) + let flowCredit = getCreditBalanceForType( + details: details, + vaultType: CompositeType(FLOW_TOKEN_IDENTIFIER)! + ) + Test.assertEqual(1000.0, flowCredit) +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 1d422c6c..93801bb3 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -9,6 +9,7 @@ import "MOET" access(all) let MOET_TOKEN_IDENTIFIER = "A.0000000000000007.MOET.Vault" access(all) let FLOW_TOKEN_IDENTIFIER = "A.0000000000000003.FlowToken.Vault" access(all) let FLOW_VAULT_STORAGE_PATH = /storage/flowTokenVault +access(all) let FLOW_VAULT_PUBLIC_PATH = /public/flowTokenReceiver access(all) let PROTOCOL_ACCOUNT = Test.getAccount(0x0000000000000007) access(all) let NON_ADMIN_ACCOUNT = Test.getAccount(0x0000000000000008) diff --git a/cadence/tests/transactions/position-manager/create_position_reentrant_sink.cdc b/cadence/tests/transactions/position-manager/create_position_reentrant_sink.cdc new file mode 100644 index 00000000..f0961ae5 --- /dev/null +++ b/cadence/tests/transactions/position-manager/create_position_reentrant_sink.cdc @@ -0,0 +1,143 @@ +import "FungibleToken" + +import "DeFiActions" +import "FungibleTokenConnectors" +import "AdversarialReentrancyConnectors" + +import "MOET" +import "FlowToken" +import "FlowALPv0" +import "FlowALPPositionResources" +import "FlowALPModels" + +/// TEST TRANSACTION — DO NOT USE IN PRODUCTION +/// +/// Opens a FlowALPv0 position wired with VaultSinkHacked as its issuanceSink +/// (drawDownSink), mirroring how create_position_reentrant_source.cdc wires +/// VaultSourceHacked as repaymentSource. +/// +/// The sink is passed directly to createPosition() so the pool stores it +/// internally via setDrawDownSink on the InternalPosition — the only place +/// where that setter is accessible. +/// +/// When the pool later calls sink.depositCapacity(from:) during a rebalance / +/// drawdown push, VaultSinkHacked calls position.depositAndPush() on the same +/// pid while the position lock is held. The reentrancy guard rejects it and +/// reverts the entire transaction. +/// +/// Parameters: +/// amount — FLOW collateral to deposit +/// vaultStoragePath — storage path of the FLOW vault +/// pushToDrawDownSink — whether to trigger the sink immediately on open +transaction( + amount: UFix64, + vaultStoragePath: StoragePath, + pushToDrawDownSink: Bool +) { + let collateral: @{FungibleToken.Vault} + let sink: {DeFiActions.Sink} + let positionManager: auth(FlowALPModels.EPositionAdmin) &FlowALPPositionResources.PositionManager + let poolCap: Capability + let signerAccount: auth( + LoadValue, BorrowValue, SaveValue, + IssueStorageCapabilityController, PublishCapability, UnpublishCapability + ) &Account + + prepare(signer: auth( + LoadValue, BorrowValue, SaveValue, + IssueStorageCapabilityController, PublishCapability, UnpublishCapability + ) &Account) { + self.signerAccount = signer + + // Withdraw collateral from the signer's vault. + let collateralVault = signer.storage.borrow( + from: vaultStoragePath + ) ?? panic("Could not borrow vault from \(vaultStoragePath)") + self.collateral <- collateralVault.withdraw(amount: amount) + + let liveDataStoragePath = /storage/sinkLiveDataResource + if signer.storage.type(at: liveDataStoragePath) != nil { + let old <- signer.storage.load<@AdversarialReentrancyConnectors.LiveData>( + from: liveDataStoragePath + ) + destroy old + } + signer.storage.save( + <- AdversarialReentrancyConnectors.createLiveData(), + to: liveDataStoragePath + ) + let liveDataCap = signer.capabilities.storage.issue< + &AdversarialReentrancyConnectors.LiveData + >(liveDataStoragePath) + + // VaultSinkHacked requires a real depositVault capability to pass its + // init pre-condition (depositVault.check() must be true). + // We use the signer's MOET public capability; getSinkType() returns MOET + // so the pool's setDrawDownSink pre-condition is also satisfied. + let moetDepositCap = signer.capabilities.get<&{FungibleToken.Vault}>(MOET.VaultPublicPath) + assert( + moetDepositCap.check(), + message: "MOET vault public capability not found — run setupMoetVault first" + ) + + // Build VaultSinkHacked. LiveData is empty at this point (pid not yet + // known); it is populated in execute() after createPosition returns. + self.sink = AdversarialReentrancyConnectors.VaultSinkHacked( + max: nil, + depositVault: moetDepositCap, + uniqueID: nil, + liveDataCap: liveDataCap + ) + + // Get or create PositionManager. + if signer.storage.borrow<&FlowALPPositionResources.PositionManager>( + from: FlowALPv0.PositionStoragePath + ) == nil { + let manager <- FlowALPv0.createPositionManager() + signer.storage.save(<-manager, to: FlowALPv0.PositionStoragePath) + let readCap = signer.capabilities.storage.issue< + &FlowALPPositionResources.PositionManager + >(FlowALPv0.PositionStoragePath) + signer.capabilities.publish(readCap, at: FlowALPv0.PositionPublicPath) + } + self.positionManager = signer.storage.borrow< + auth(FlowALPModels.EPositionAdmin) &FlowALPPositionResources.PositionManager + >(from: FlowALPv0.PositionStoragePath) ?? panic("PositionManager not found") + + self.poolCap = signer.storage.load< + Capability + >(from: FlowALPv0.PoolCapStoragePath) + ?? panic("Could not load Pool capability — ensure EParticipant access was granted") + } + + execute { + let poolRef = self.poolCap.borrow() ?? panic("Could not borrow Pool capability") + + // Pass VaultSinkHacked as issuanceSink — createPosition calls + // iPos.setDrawDownSink(issuanceSink) on the InternalPosition, which is + // the only code path that can write the drawDownSink field. + // pushToDrawDownSink is always false here; the adversarial trigger + // comes from the caller after LiveData has been populated. + let position <- poolRef.createPosition( + funds: <-self.collateral, + issuanceSink: self.sink, + repaymentSource: nil, + pushToDrawDownSink: false + ) + let pid = position.id + self.positionManager.addPosition(position: <-position) + + // Populate LiveData now that pid is known, so the sink can make the + // re-entrant call when depositCapacity() is invoked. + let liveDataCap = self.signerAccount.capabilities.storage.issue< + &AdversarialReentrancyConnectors.LiveData + >(/storage/sinkLiveDataResource) + let liveData = liveDataCap.borrow() ?? panic("Cannot borrow LiveData") + let managerCap = self.signerAccount.capabilities.storage.issue< + auth(FungibleToken.Withdraw, FlowALPModels.EPositionAdmin) &FlowALPPositionResources.PositionManager + >(FlowALPv0.PositionStoragePath) + liveData.setRecursivePosition(managerCap: managerCap, pid: pid) + + self.signerAccount.storage.save(self.poolCap, to: FlowALPv0.PoolCapStoragePath) + } +} \ No newline at end of file diff --git a/cadence/tests/transactions/position-manager/create_position_reentrancy.cdc b/cadence/tests/transactions/position-manager/create_position_reentrant_source.cdc similarity index 100% rename from cadence/tests/transactions/position-manager/create_position_reentrancy.cdc rename to cadence/tests/transactions/position-manager/create_position_reentrant_source.cdc