Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9036283
Add mainnet fork test for multiple positions per user and batch liqui…
mts1715 Feb 18, 2026
4c2bf16
Add testMassUnhealthyLiquidations — a system-wide stress test that cr…
mts1715 Feb 19, 2026
bf31254
Merge remote-tracking branch 'origin/main' into taras/147-multi-posit…
mts1715 Feb 19, 2026
441c58e
fixes after merge
mts1715 Feb 19, 2026
86086fa
code style fix
mts1715 Feb 19, 2026
526704a
constant naming fix
mts1715 Feb 20, 2026
13c50e5
moved helper methods to test_helpers.cdc
mts1715 Feb 24, 2026
e7216bd
Update cadence/tests/fork_multiple_positions_per_user.cdc
mts1715 Feb 25, 2026
d064bc7
fix naming *_test.cdc, move not production script to test dir
mts1715 Feb 25, 2026
887ae21
flow.json: fix block height
mts1715 Feb 25, 2026
0df9e00
fix block height in flow.json, add actions/cache for ./imports keyed …
mts1715 Feb 25, 2026
c9cc894
add more description comments to test
mts1715 Feb 26, 2026
0e5577c
move scripts only for test to "cadence/test/transactions"
mts1715 Feb 26, 2026
5bc3cf1
remove useless transaction from test
mts1715 Feb 26, 2026
8fd216c
Merge branch 'main' into taras/147-multi-position-per-user-scenarios-…
mts1715 Mar 2, 2026
e5e5824
Merge branch 'main' into taras/147-multi-position-per-user-scenarios-…
mts1715 Mar 3, 2026
d7cefa5
Merge branch 'main' into taras/147-multi-position-per-user-scenarios-…
Kay-Zee Mar 6, 2026
d98a0aa
fix borrow asset to MOET instead of FLOW, fix liqudate transactions t…
mts1715 Mar 10, 2026
828600a
use constants instead of magic numbers in fork_multiple_positions_per…
mts1715 Mar 10, 2026
6e9d39c
Merge remote-tracking branch 'origin/main' into taras/147-multi-posit…
mts1715 Mar 10, 2026
d1ff732
Merge branch 'main' into taras/147-multi-position-per-user-scenarios-…
mts1715 Mar 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/cadence_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- uses: actions/cache@v4
with:
path: ./imports
key: flow-deps-${{ hashFiles('flow.json') }}
- name: Install Flow CLI
env:
FLOW_CLI_VERSION: v2.7.2
Expand Down
987 changes: 987 additions & 0 deletions cadence/tests/fork_multiple_positions_per_user_test.cdc

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions cadence/tests/scripts/get_oracle_price.cdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import "MockOracle"

access(all) fun main(tokenIdentifier: String): UFix64? {
let tokenType = CompositeType(tokenIdentifier)
?? panic("Invalid token identifier: ".concat(tokenIdentifier))

let oracle = MockOracle.PriceOracle()
return oracle.price(ofToken: tokenType)
}
90 changes: 88 additions & 2 deletions cadence/tests/test_helpers.cdc
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Test
import "FlowALPv0"
import "FlowALPModels"
import "FlowALPEvents"
import "MOET"

/* --- Global test constants --- */
Expand All @@ -25,7 +26,7 @@ access(all) let MAX_HEALTH = 1.5
access(all) let INT_MIN_HEALTH: UFix128 = 1.1
access(all) let INT_TARGET_HEALTH: UFix128 = 1.3
access(all) let INT_MAX_HEALTH: UFix128 = 1.5
access(all) let CEILING_HEALTH: UFix128 = UFix128.max // infinite health when debt ~ 0.0
access(all) let CEILING_HEALTH = UFix128.max // infinite health when debt ~ 0.0

// Time constants
access(all) let DAY: Fix64 = 86_400.0
Expand All @@ -38,11 +39,12 @@ access(all) let ONE_YEAR: Fix64 = 31_557_600.0 // 365.25 * 86400
access(all) let MAINNET_WETH_TOKEN_ID = "A.1e4aa0b87d10b141.EVMVMBridgedToken_2f6f07cdcf3588944bf4c42ac74ff24bf56e7590.Vault"
access(all) let MAINNET_USDF_TOKEN_ID = "A.1e4aa0b87d10b141.EVMVMBridgedToken_2aabea2058b5ac2d339b163c6ab6f2b6d53aabed.Vault"
access(all) let MAINNET_WBTC_TOKEN_ID = "A.1e4aa0b87d10b141.EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579.Vault"

access(all) let MAINNET_USDC_TOKEN_ID = "A.f1ab99c82dee3526.USDCFlow.Vault"
access(all) let MAINNET_MOET_TOKEN_ID = "A.6b00ff876c299c61.MOET.Vault"
access(all) let MAINNET_FLOW_TOKEN_ID = "A.1654653399040a61.FlowToken.Vault"

// Storage paths
access(all) let MAINNET_USDC_STORAGE_PATH = /storage/usdcFlowVault
access(all) let MAINNET_USDF_STORAGE_PATH = /storage/EVMVMBridgedToken_2aabea2058b5ac2d339b163c6ab6f2b6d53aabedVault
access(all) let MAINNET_WETH_STORAGE_PATH = /storage/EVMVMBridgedToken_2f6f07cdcf3588944bf4c42ac74ff24bf56e7590Vault
access(all) let MAINNET_WBTC_STORAGE_PATH = /storage/EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579Vault
Expand All @@ -52,6 +54,8 @@ access(all) let MAINNET_PROTOCOL_ACCOUNT_ADDRESS: Address = 0x6b00ff876c299c61
access(all) let MAINNET_USDF_HOLDER_ADDRESS: Address = 0xf18b50870aed46ad
access(all) let MAINNET_WETH_HOLDER_ADDRESS: Address = 0xf62e3381a164f993
access(all) let MAINNET_WBTC_HOLDER_ADDRESS: Address = 0x47f544294e3b7656
access(all) let MAINNET_FLOW_HOLDER_ADDRESS: Address = 0xe467b9dd11fa00df
access(all) let MAINNET_USDC_HOLDER_ADDRESS: Address = 0xec6119051f7adc31

/* --- Test execution helpers --- */

Expand Down Expand Up @@ -419,6 +423,23 @@ fun setMockOraclePrice(signer: Test.TestAccount, forTokenIdentifier: String, pri
Test.expect(setRes, Test.beSucceeded())
}

access(all)
fun getOraclePrice(tokenIdentifier: String): UFix64 {
let result = Test.executeScript(
Test.readFile("./scripts/get_oracle_price.cdc"),
[tokenIdentifier]
)

if result.error != nil {
panic("Failed to get oracle price: ".concat(result.error!.message))
}

let price = result.returnValue! as! UFix64?
?? panic("No price set for token: ".concat(tokenIdentifier))

return price
}

/// Sets a swapper for the given pair with the given price ratio.
/// This overwrites any previously stored swapper for this pair, if any exists.
/// This is intended to be used in tests both to set an initial DEX price for a supported token,
Expand Down Expand Up @@ -856,6 +877,65 @@ fun transferFungibleTokens(
Test.expect(res, Test.beSucceeded())
}

/// Sets up the recipient's vault (if not already present) and transfers tokens in one call.
/// Combines setupGenericVault + transferFungibleTokens for the common case of funding a fresh account.
access(all)
fun transferTokensWithSetup(tokenIdentifier: String, from: Test.TestAccount, to: Test.TestAccount, amount: UFix64) {
let res = setupGenericVault(to, vaultIdentifier: tokenIdentifier)
Test.expect(res, Test.beSucceeded())
transferFungibleTokens(tokenIdentifier: tokenIdentifier, from: from, to: to, amount: amount)
}

/// Batch-liquidate positions using the liquidator's own tokens as repayment (no DEX).
/// The liquidator must hold sufficient debt tokens upfront.
access(all) fun batchManualLiquidation(
pids: [UInt64],
debtVaultIdentifier: String,
seizeVaultIdentifiers: [String],
seizeAmounts: [UFix64],
repayAmounts: [UFix64],
signer: Test.TestAccount
) {
let res = _executeTransaction(
"./transactions/flow-alp/pool-management/batch_manual_liquidation.cdc",
[pids, debtVaultIdentifier, seizeVaultIdentifiers, seizeAmounts, repayAmounts],
signer
)
Test.expect(res, Test.beSucceeded())
}

/// Batch-liquidate positions using MockDexSwapper as the repayment source in chunks of
/// chunkSize to stay within the computation limit.
access(all) fun batchLiquidateViaMockDex(
pids: [UInt64],
debtVaultIdentifier: String,
seizeVaultIdentifiers: [String],
seizeAmounts: [UFix64],
repayAmounts: [UFix64],
chunkSize: Int,
signer: Test.TestAccount
) {
let total = pids.length
let numChunks = (total + chunkSize - 1) / chunkSize
for i in InclusiveRange(0, numChunks - 1) {
let startIdx = i * chunkSize
var endIdx = startIdx + chunkSize
if endIdx > total {
endIdx = total
}
let res = _executeTransaction(
"./transactions/flow-alp/pool-management/batch_liquidate_via_mock_dex.cdc",
[pids.slice(from: startIdx, upTo: endIdx),
debtVaultIdentifier,
seizeVaultIdentifiers.slice(from: startIdx, upTo: endIdx),
seizeAmounts.slice(from: startIdx, upTo: endIdx),
repayAmounts.slice(from: startIdx, upTo: endIdx)],
signer
)
Test.expect(res, Test.beSucceeded())
}
}

access(all)
fun expectEvents(eventType: Type, expectedCount: Int) {
let events = Test.eventsOfType(eventType)
Expand Down Expand Up @@ -933,3 +1013,9 @@ fun getCreditBalanceForType(details: FlowALPModels.PositionDetails, vaultType: T
}
return 0.0
}

access(all) fun getLastPositionId(): UInt64 {
var openEvents = Test.eventsOfType(Type<FlowALPEvents.Opened>())
let pid = (openEvents[openEvents.length - 1] as! FlowALPEvents.Opened).pid
return pid
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import "FungibleToken"
import "FungibleTokenMetadataViews"
import "MetadataViews"

import "FlowALPv0"
import "MockDexSwapper"

/// TEST-ONLY: Batch liquidate multiple positions using the stored MockDexSwapper as the debt
/// repayment source. The swapper's vaultSource (configured via setMockDexPriceForPair) withdraws
/// the required debt tokens, so the transaction signer needs no debt tokens upfront.
///
/// Positions are liquidated in the order provided (caller is responsible for ordering by priority).
///
/// pids: Array of position IDs to liquidate
/// debtVaultIdentifier: e.g., Type<@FlowToken.Vault>().identifier
/// seizeVaultIdentifiers: Array of collateral vault identifiers to seize (one per position)
/// seizeAmounts: Array of collateral amounts to seize from each position
/// repayAmounts: Array of debt amounts to repay for each position (sourced from the DEX)
transaction(
pids: [UInt64],
repayVaultIdentifier: String,
seizeVaultIdentifiers: [String],
seizeAmounts: [UFix64],
repayAmounts: [UFix64]
) {
let pool: &FlowALPv0.Pool
let debtType: Type
let signerAccount: auth(BorrowValue) &Account

prepare(signer: auth(BorrowValue) &Account) {
let protocolAddress = Type<@FlowALPv0.Pool>().address!
self.pool = getAccount(protocolAddress).capabilities.borrow<&FlowALPv0.Pool>(FlowALPv0.PoolPublicPath)
?? panic("Could not borrow Pool at \(FlowALPv0.PoolPublicPath)")

self.debtType = CompositeType(repayVaultIdentifier)
?? panic("Invalid debtVaultIdentifier: \(repayVaultIdentifier)")

self.signerAccount = signer
}

execute {
let numPositions = pids.length
assert(seizeVaultIdentifiers.length == numPositions, message: "seizeVaultIdentifiers length mismatch")
assert(seizeAmounts.length == numPositions, message: "seizeAmounts length mismatch")
assert(repayAmounts.length == numPositions, message: "repayAmounts length mismatch")

var totalRepaid = 0.0

for idx in InclusiveRange(0, numPositions - 1) {
let pid = pids[idx]
let seizeVaultIdentifier = seizeVaultIdentifiers[idx]
let seizeAmount = seizeAmounts[idx]
let repayAmount = repayAmounts[idx]

let seizeType = CompositeType(seizeVaultIdentifier)
?? panic("Invalid seizeVaultIdentifier: \(seizeVaultIdentifier)")

// Retrieve the stored MockDexSwapper for this collateral → debt pair.
// The swapper's vaultSource (protocolAccount's vault) provides the debt tokens.
let swapper = MockDexSwapper.getSwapper(inType: seizeType, outType: self.debtType)
?? panic("No MockDexSwapper configured for \(seizeVaultIdentifier) -> \(repayVaultIdentifier)")

// Build an exact quote for the repayAmount we need from the swapper's vaultSource
let swapQuote = MockDexSwapper.BasicQuote(
inType: seizeType,
outType: self.debtType,
inAmount: 0.0,
outAmount: repayAmount
)

// Create an empty collateral vault as a dummy swap input — MockDexSwapper burns it
// and withdraws repayAmount debt tokens from its configured vaultSource instead.
let seizeVaultData = MetadataViews.resolveContractViewFromTypeIdentifier(
resourceTypeIdentifier: seizeVaultIdentifier,
viewType: Type<FungibleTokenMetadataViews.FTVaultData>()
) as? FungibleTokenMetadataViews.FTVaultData
?? panic("Could not resolve FTVaultData for \(seizeVaultIdentifier)")
let emptyCollateralVault <- seizeVaultData.createEmptyVault()

// Swap: burns emptyCollateralVault, withdraws repayAmount from vaultSource
let repayVault <- swapper.swap(quote: swapQuote, inVault: <-emptyCollateralVault)

// Execute the liquidation: pool seizes collateral, caller provides repayment
let seizedVault <- self.pool.manualLiquidation(
pid: pid,
debtType: self.debtType,
seizeType: seizeType,
seizeAmount: seizeAmount,
repayment: <-repayVault
)

totalRepaid = totalRepaid + repayAmount

// Deposit seized collateral back to liquidator
let liquidatorVault = self.signerAccount.storage.borrow<&{FungibleToken.Vault}>(from: seizeVaultData.storagePath)
?? panic("No vault at \(seizeVaultData.storagePath) to deposit seized collateral")
liquidatorVault.deposit(from: <-seizedVault)
}

log("Batch DEX liquidation completed: \(numPositions) positions, total repaid: \(totalRepaid)")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import "FungibleToken"
import "FungibleTokenMetadataViews"
import "MetadataViews"

import "FlowALPv0"

/// Batch liquidate multiple positions in a single transaction
///
/// pids: Array of position IDs to liquidate
/// repaymentVaultIdentifier: e.g., Type<@FlowToken.Vault>().identifier
/// seizeVaultIdentifiers: Array of collateral vault identifiers to seize
/// seizeAmounts: Array of max seize amounts for each position
/// repayAmounts: Array of repay amounts for each position
transaction(
pids: [UInt64],
repaymentVaultIdentifier: String,
seizeVaultIdentifiers: [String],
seizeAmounts: [UFix64],
repayAmounts: [UFix64]
) {
let pool: &FlowALPv0.Pool
let repaymentType: Type
let repaymentVaultRef: auth(FungibleToken.Withdraw) &{FungibleToken.Vault}
let signerAccount: auth(BorrowValue) &Account

prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) {
self.signerAccount = signer

let protocolAddress = Type<@FlowALPv0.Pool>().address!
self.pool = getAccount(protocolAddress).capabilities.borrow<&FlowALPv0.Pool>(FlowALPv0.PoolPublicPath)
?? panic("Could not borrow Pool at \(FlowALPv0.PoolPublicPath)")

self.repaymentType = CompositeType(repaymentVaultIdentifier) ?? panic("Invalid repaymentVaultIdentifier: \(repaymentVaultIdentifier)")

let repaymentVaultData = MetadataViews.resolveContractViewFromTypeIdentifier(
resourceTypeIdentifier: repaymentVaultIdentifier,
viewType: Type<FungibleTokenMetadataViews.FTVaultData>()
) as? FungibleTokenMetadataViews.FTVaultData
?? panic("Could not construct valid FT type and view from identifier \(repaymentVaultIdentifier)")

self.repaymentVaultRef = signer.storage.borrow<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(from: repaymentVaultData.storagePath)
?? panic("no repayment vault in storage at path \(repaymentVaultData.storagePath)")
}

execute {
let numPositions = pids.length
assert(seizeVaultIdentifiers.length == numPositions, message: "seizeVaultIdentifiers length mismatch")
assert(seizeAmounts.length == numPositions, message: "seizeAmounts length mismatch")
assert(repayAmounts.length == numPositions, message: "repayAmounts length mismatch")

var totalRepaid = 0.0

for i in InclusiveRange(0, numPositions - 1) {
let pid = pids[i]
let seizeVaultIdentifier = seizeVaultIdentifiers[i]
let seizeAmount = seizeAmounts[i]
let repayAmount = repayAmounts[i]

let seizeType = CompositeType(seizeVaultIdentifier)
?? panic("Invalid seizeVaultIdentifier: \(seizeVaultIdentifier)")

assert(self.repaymentVaultRef.balance >= repayAmount,
message: "Insufficient repayment token balance for position \(pid)")

let repay <- self.repaymentVaultRef.withdraw(amount: repayAmount)

let seizedVault <- self.pool.manualLiquidation(
pid: pid,
debtType: self.repaymentType,
seizeType: seizeType,
seizeAmount: seizeAmount,
repayment: <-repay
)

totalRepaid = totalRepaid + repayAmount

// Deposit seized collateral back to liquidator
let seizeVaultData = MetadataViews.resolveContractViewFromTypeIdentifier(
resourceTypeIdentifier: seizeVaultIdentifier,
viewType: Type<FungibleTokenMetadataViews.FTVaultData>()
) as? FungibleTokenMetadataViews.FTVaultData
?? panic("Could not resolve FTVaultData for \(seizeVaultIdentifier)")
let liquidatorVault = self.signerAccount.storage.borrow<&{FungibleToken.Vault}>(from: seizeVaultData.storagePath)
?? panic("No vault at \(seizeVaultData.storagePath) to deposit seized collateral")
liquidatorVault.deposit(from: <-seizedVault)
}

log("Batch liquidation completed: \(numPositions) positions, total repaid: \(totalRepaid)")
}
}
Loading