diff --git a/Cargo.lock b/Cargo.lock index ce4762627994c..c2fffddddf099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14237,6 +14237,23 @@ dependencies = [ "sp-runtime 31.0.1", ] +[[package]] +name = "pallet-vaults" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "pallet-assets", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "sp-io 30.0.0", + "sp-runtime 31.0.1", + "staging-xcm", +] + [[package]] name = "pallet-verify-signature" version = "1.0.0" @@ -16623,6 +16640,7 @@ dependencies = [ "pallet-tx-pause", "pallet-uniques", "pallet-utility", + "pallet-vaults", "pallet-verify-signature", "pallet-vesting", "pallet-whitelist", diff --git a/Cargo.toml b/Cargo.toml index 543bdaad6cbe1..78530e2f43810 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -477,6 +477,7 @@ members = [ "substrate/frame/tx-pause", "substrate/frame/uniques", "substrate/frame/utility", + "substrate/frame/vaults", "substrate/frame/verify-signature", "substrate/frame/vesting", "substrate/frame/whitelist", @@ -1089,6 +1090,7 @@ pallet-treasury = { path = "substrate/frame/treasury", default-features = false pallet-tx-pause = { default-features = false, path = "substrate/frame/tx-pause" } pallet-uniques = { path = "substrate/frame/uniques", default-features = false } pallet-utility = { path = "substrate/frame/utility", default-features = false } +pallet-vaults = { path = "substrate/frame/vaults", default-features = false } pallet-verify-signature = { path = "substrate/frame/verify-signature", default-features = false } pallet-vesting = { path = "substrate/frame/vesting", default-features = false } pallet-whitelist = { path = "substrate/frame/whitelist", default-features = false } diff --git a/substrate/bin/node/runtime/src/genesis_config_presets.rs b/substrate/bin/node/runtime/src/genesis_config_presets.rs index fb667a64a7184..81541e127ed13 100644 --- a/substrate/bin/node/runtime/src/genesis_config_presets.rs +++ b/substrate/bin/node/runtime/src/genesis_config_presets.rs @@ -23,7 +23,7 @@ use crate::{ constants::currency::*, frame_support::build_struct_json_patch, AccountId, AssetsConfig, BabeConfig, Balance, BalancesConfig, ElectionsConfig, NominationPoolsConfig, ReviveConfig, RuntimeGenesisConfig, SessionConfig, SessionKeys, SocietyConfig, StakerStatus, StakingConfig, - SudoConfig, TechnicalCommitteeConfig, BABE_GENESIS_EPOCH_CONFIG, + SudoConfig, TechnicalCommitteeConfig, VaultsConfig, BABE_GENESIS_EPOCH_CONFIG, }; use alloc::{vec, vec::Vec}; use pallet_im_online::sr25519::AuthorityId as ImOnlineId; @@ -98,6 +98,30 @@ pub fn kitchensink_genesis( revive: ReviveConfig { mapped_accounts: endowed_accounts.iter().filter(|x| ! is_eth_derived(x)).cloned().collect(), }, + vaults: VaultsConfig { + // 180% - Minimum safety margin before liquidation + minimum_collateralization_ratio: sp_runtime::FixedU128::from_rational(180, 100), + // 200% - Prevents immediate liquidation after minting + initial_collateralization_ratio: sp_runtime::FixedU128::from_rational(200, 100), + // 4% annual stability fee + stability_fee: sp_runtime::Permill::from_percent(4), + // 13% liquidation penalty + liquidation_penalty: sp_runtime::Permill::from_percent(13), + // 20M pUSD system-wide debt ceiling (6 decimals) + maximum_issuance: 20_000_000 * 1_000_000, + // 20M pUSD maximum concurrent liquidation exposure + max_liquidation_amount: 20_000_000 * 1_000_000, + // 10M pUSD maximum single vault debt + max_position_amount: 10_000_000 * 1_000_000, + // Minimum collateral deposit to create a vault: 100 DOT. + minimum_deposit: 100 * DOLLARS, + // Minimum mint amount: 5 pUSD. + minimum_mint: 5 * 1_000_000, + // 4 hours stale vault threshold (milliseconds) + stale_vault_threshold: 4 * 60 * 60 * 1000, + // 1 hour oracle staleness threshold (milliseconds) + oracle_staleness_threshold: 60 * 60 * 1000, + }, }) } diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index b4c8b5cd2a495..274e3a140a1d5 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -32,7 +32,7 @@ use pallet_treasury::ArgumentsFactory as PalletTreasuryArgumentsFactory; #[cfg(feature = "runtime-benchmarks")] use polkadot_sdk::sp_core::crypto::FromEntropy; -use polkadot_sdk::*; +use polkadot_sdk::{cumulus_primitives_core::Location, *}; use alloc::{vec, vec::Vec}; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; @@ -2881,6 +2881,9 @@ mod runtime { #[runtime::pallet_index(85)] pub type Oracle = pallet_oracle::Pallet; + #[runtime::pallet_index(86)] + pub type Vaults = pallet_vaults::Pallet; + #[runtime::pallet_index(89)] pub type MetaTx = pallet_meta_tx::Pallet; @@ -3062,6 +3065,183 @@ impl pallet_oracle::Config for Runtime { type BenchmarkHelper = OracleBenchmarkingHelper; } +parameter_types! { + /// The pUSD stablecoin asset ID. + pub const VaultsStablecoinAssetId: u32 = 1; + /// DOT collateral location. + pub VaultsCollateralLocation: Location = Location::here(); + /// Maximum vaults to process in on_idle per block. + pub const VaultsMaxOnIdleItems: u32 = 16; +} + +type VaultsAsset = ItemOf; + +/// Insurance fund account that receives protocol revenue (interest and penalties). +pub struct InsuranceFundAccount; +impl frame_support::traits::Get for InsuranceFundAccount { + fn get() -> AccountId { + // Use a deterministic insurance fund account + sp_runtime::traits::AccountIdConversion::::into_account_truncating( + &frame_support::PalletId(*b"py/insur"), + ) + } +} + +/// Mock oracle adapter that provides a fixed price for now. +/// +/// This adapter implements `ProvidePrice` with a hardcoded DOT price. +/// For production, replace this with a real oracle integration. +/// +/// **Price format (normalized):** smallest_pUSD_units / smallest_collateral_unit +/// +/// Default DOT price: $4.21 normalized for DOT (10 decimals) and pUSD (6 decimals) +/// Price = 4.21 * 10^6 / 10^10 = 0.000421 +/// As FixedU128: 421_000_000_000_000 (0.000421 * 10^18) +pub const DEFAULT_DOT_PRICE: u128 = 421_000_000_000_000; + +#[cfg(feature = "runtime-benchmarks")] +frame_support::parameter_types! { + /// Storage for benchmark price override. + /// When set, MockOracleAdapter will use this price instead of the default. + pub storage BenchmarkOraclePrice: Option = None; +} + +/// Example calculation for DOT at $4.21: +/// - DOT has 10 decimals, pUSD has 6 decimals +/// - 1 DOT = 4.21 pUSD +/// - Normalized price = 4.21 × 10^6 / 10^10 = 0.000421 +/// - As FixedU128 (18 decimals): 0.000421 × 10^18 = 421_000_000_000_000 +pub struct MockOracleAdapter; +impl pallet_vaults::ProvidePrice for MockOracleAdapter { + type Moment = u64; + + fn get_price(asset: &Location) -> Option<(FixedU128, Self::Moment)> { + // Only support DOT (native asset) for now + if *asset != Location::here() { + return None; + } + + // Check for benchmark price override + #[cfg(feature = "runtime-benchmarks")] + let price = BenchmarkOraclePrice::get().unwrap_or(FixedU128::from_inner(DEFAULT_DOT_PRICE)); + + #[cfg(not(feature = "runtime-benchmarks"))] + let price = FixedU128::from_inner(DEFAULT_DOT_PRICE); + + // Use current timestamp from the Timestamp pallet + let now = ::now(); + + Some((price, now)) + } +} + +/// Stub implementation for the Auctions handler. +/// +/// This is a placeholder until a proper Auctions pallet is implemented. +/// Currently, liquidations will fail with `Unimplemented` error. +/// +/// TODO: Replace with actual pallet_auctions integration when available. +pub struct AuctionAdapter; +impl pallet_vaults::AuctionsHandler for AuctionAdapter { + fn start_auction( + _vault_owner: AccountId, + _collateral_amount: Balance, + _debt: frame_support::traits::DebtComponents, + _keeper: AccountId, + ) -> Result { + // During benchmarks, return success to allow liquidation benchmarks to complete + #[cfg(feature = "runtime-benchmarks")] + return Ok(1); + + // TODO: Implement actual auction logic when pallet_auctions is available + // For now, liquidations are disabled + #[cfg(not(feature = "runtime-benchmarks"))] + Err(frame_support::pallet_prelude::DispatchError::Other("Auctions not yet implemented")) + } +} + +/// EnsureOrigin implementation for vaults management that supports privilege levels. +/// +/// - Root origin → `VaultsManagerLevel::Full` (can modify all parameters) +/// +/// TODO: In the future, this can be extended to support Emergency privilege level +/// via a (new) specific governance origin that can only lower the debt ceiling. +pub struct EnsureVaultsManager; +impl frame_support::traits::EnsureOrigin for EnsureVaultsManager { + type Success = pallet_vaults::VaultsManagerLevel; + + fn try_origin(o: RuntimeOrigin) -> Result { + use frame_system::RawOrigin; + + match o.clone().into() { + Ok(RawOrigin::Root) => Ok(pallet_vaults::VaultsManagerLevel::Full), + _ => Err(o), + } + } + + #[cfg(feature = "runtime-benchmarks")] + fn try_successful_origin() -> Result { + Ok(RuntimeOrigin::root()) + } +} + +/// Benchmark helper for the Vaults pallet. +#[cfg(feature = "runtime-benchmarks")] +pub struct VaultsBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_vaults::BenchmarkHelper for VaultsBenchmarkHelper { + fn create_stablecoin_asset() { + use frame_support::traits::fungibles::Create; + + // Ensure the owner account exists + let owner = InsuranceFundAccount::get(); + let _ = frame_system::Pallet::::inc_providers(&owner); + + // Create the asset if it doesn't exist (ignore errors if already exists) + let _ = >::create( + VaultsStablecoinAssetId::get(), + owner.clone(), + true, // is_sufficient + 1, // min_balance + ); + } + + fn advance_time(millis: u64) { + let current = pallet_timestamp::Now::::get(); + let new_time = current.saturating_add(millis); + pallet_timestamp::Now::::put(new_time); + } + + fn set_price(price: sp_runtime::FixedU128) { + BenchmarkOraclePrice::set(&Some(price)); + log::info!( + target: "runtime::vaults::benchmark", + "set_price: {:?}", + price + ); + } +} + +/// Configure the Vaults pallet. +impl pallet_vaults::Config for Runtime { + type Collateral = Balances; + type RuntimeHoldReason = RuntimeHoldReason; + type StableAsset = VaultsAsset; + type InsuranceFund = InsuranceFundAccount; + type FeeHandler = ResolveTo; + type SurplusHandler = ResolveTo; + type TimeProvider = Timestamp; + type ManagerOrigin = EnsureVaultsManager; + type MaxOnIdleItems = VaultsMaxOnIdleItems; + type Oracle = MockOracleAdapter; + type CollateralLocation = VaultsCollateralLocation; + type AuctionsHandler = AuctionAdapter; + type WeightInfo = pallet_vaults::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = VaultsBenchmarkHelper; +} + /// MMR helper types. mod mmr { use super::*; @@ -3200,6 +3380,7 @@ mod benches { [pallet_nft_fractionalization, NftFractionalization] [pallet_utility, Utility] [pallet_vesting, Vesting] + [pallet_vaults, Vaults] [pallet_whitelist, Whitelist] [pallet_tx_pause, TxPause] [pallet_safe_mode, SafeMode] diff --git a/substrate/frame/support/src/traits.rs b/substrate/frame/support/src/traits.rs index ea6e9aa2e9a8f..50e6b53a20533 100644 --- a/substrate/frame/support/src/traits.rs +++ b/substrate/frame/support/src/traits.rs @@ -28,8 +28,12 @@ pub use tokens::{ }, fungible, fungibles, imbalance::{Imbalance, OnUnbalanced, SignedImbalance}, - nonfungible, nonfungible_v2, nonfungibles, nonfungibles_v2, BalanceStatus, - ExistenceRequirement, Locker, WithdrawReasons, + nonfungible, nonfungible_v2, nonfungibles, nonfungibles_v2, + stable::{ + AuctionsHandler, CollateralManager, DebtComponents, PaymentBreakdown, PsmInterface, + VaultsInterface, + }, + BalanceStatus, ExistenceRequirement, Locker, WithdrawReasons, }; mod members; diff --git a/substrate/frame/support/src/traits/tokens.rs b/substrate/frame/support/src/traits/tokens.rs index be982cd31e33a..be32e1fada537 100644 --- a/substrate/frame/support/src/traits/tokens.rs +++ b/substrate/frame/support/src/traits/tokens.rs @@ -29,6 +29,7 @@ pub mod nonfungibles; pub mod nonfungibles_v2; pub use imbalance::Imbalance; pub mod pay; +pub mod stable; pub mod transfer; pub use misc::{ AssetId, Balance, BalanceStatus, ConversionFromAssetBalance, ConversionToAssetBalance, diff --git a/substrate/frame/support/src/traits/tokens/stable.rs b/substrate/frame/support/src/traits/tokens/stable.rs new file mode 100644 index 0000000000000..2e692761712c1 --- /dev/null +++ b/substrate/frame/support/src/traits/tokens/stable.rs @@ -0,0 +1,254 @@ +// This file is part of Substrate. + +// Copyright (C) Amforc AG. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Traits and types for stablecoin inter-pallet communication. + +use crate::pallet_prelude::{DispatchError, DispatchResult}; +use codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::{FixedPointOperand, FixedU128, Saturating}; + +use super::Balance; + +/// Trait for the PSM to query the Vaults pallet for system-wide debt ceiling. +/// +/// Implemented by the Vaults pallet, called by the PSM pallet when checking +/// whether a mint would exceed the maximum pUSD issuance. +pub trait VaultsInterface { + /// The balance type. + type Balance; + + /// Get the maximum allowed pUSD issuance across the entire system. + fn get_maximum_issuance() -> Self::Balance; +} + +/// Trait exposing the PSM pallet's reserved capacity to other pallets. +/// +/// Implemented by the PSM pallet, used by the Vaults pallet to account for +/// PSM-reserved debt ceiling when calculating available vault capacity. +pub trait PsmInterface { + /// The balance type. + type Balance; + + /// Get the amount of pUSD issuance capacity reserved by the PSM. + fn reserved_capacity() -> Self::Balance; +} + +impl PsmInterface for () { + type Balance = u128; + + fn reserved_capacity() -> Self::Balance { + 0 + } +} + +/// Debt components for liquidation auctions. +/// +/// Represents the breakdown of debt that must be recovered during a liquidation auction. +/// Used when starting auctions and internally by the auctions pallet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Encode, Decode, TypeInfo, MaxEncodedLen)] +pub struct DebtComponents { + /// Principal debt - burned to maintain pUSD peg. + pub principal: Balance, + /// Accrued interest - burned (was already minted to Insurance Fund during accrual). + pub interest: Balance, + /// Liquidation penalty - transferred to Insurance Fund. + pub penalty: Balance, +} + +impl DebtComponents { + /// Total debt to recover from the auction. + pub fn total(&self) -> Balance { + self.principal.saturating_add(self.interest).saturating_add(self.penalty) + } +} + +/// Breakdown of how a payment is distributed during auction `take()`. +/// +/// Mirrors [`DebtComponents`] structure - tracks how much of each component was paid. +/// Use the computed methods [`burn()`](Self::burn) and [`insurance_fund()`](Self::insurance_fund) +/// to determine how to process the payment. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Encode, Decode, TypeInfo, MaxEncodedLen)] +pub struct PaymentBreakdown { + /// Principal portion paid. + pub principal_paid: Balance, + /// Interest portion paid (burned; was already minted to IF during accrual). + pub interest_paid: Balance, + /// Penalty portion paid (transferred to Insurance Fund). + pub penalty_paid: Balance, +} + +impl PaymentBreakdown { + /// Create new payment breakdown. + pub const fn new( + principal_paid: Balance, + interest_paid: Balance, + penalty_paid: Balance, + ) -> Self { + Self { principal_paid, interest_paid, penalty_paid } + } + + /// Amount to burn (principal + interest). + /// + /// Interest is burned because it was already minted to the Insurance Fund + /// when it accrued. Burning it on repayment balances the supply. + pub fn burn(&self) -> Balance { + self.principal_paid.saturating_add(self.interest_paid) + } + + /// Amount to transfer to Insurance Fund (penalty). + /// + /// The penalty is transferred to the IF, which temporarily holds the keeper's + /// share until auction completion. + pub const fn insurance_fund(&self) -> Balance { + self.penalty_paid + } + + /// Total payment amount. + pub fn total(&self) -> Balance { + self.burn().saturating_add(self.penalty_paid) + } +} + +/// Trait for the Vaults pallet to delegate auction lifecycle to the Auctions pallet. +/// +/// Implemented by the Auctions pallet, called by the Vaults pallet when a vault +/// needs to be liquidated. +pub trait AuctionsHandler { + /// Start a new auction for liquidating vault collateral. + /// + /// Called by the Vaults pallet when a vault becomes undercollateralized. + /// Returns the auction ID on success. + /// + /// # Parameters + /// + /// - `vault_owner`: Account whose vault is being liquidated + /// - `collateral_amount`: Amount of collateral to auction + /// - `debt`: Debt breakdown to recover (principal, interest, penalty) + /// - `keeper`: Account that triggered liquidation (receives keeper incentive) + /// + /// # Errors + /// + /// Returns an error if the circuit breaker is active or the oracle price is unavailable. + fn start_auction( + vault_owner: AccountId, + collateral_amount: Balance, + debt: DebtComponents, + keeper: AccountId, + ) -> Result; +} + +/// Trait for the Auctions pallet to call back into Vaults for asset operations. +/// +/// This trait decouples the auction logic from the asset management: +/// - Auctions pallet manages auction state (price decay, staleness, incentives computation) +/// - Vaults pallet handles all asset operations (holds, transfers, pricing, minting/burning) +pub trait CollateralManager { + /// The balance type used for collateral and debt amounts. + type Balance: Balance + FixedPointOperand; + + /// Get current collateral price from oracle. + /// + /// Returns the normalized price: `smallest_pUSD_units / smallest_collateral_unit`. + /// Used by auctions for `restart_auction()` to set new starting price. + fn get_dot_price() -> Option; + + /// Execute a purchase: collect pUSD from buyer, transfer collateral to recipient. + /// + /// Called during `take()`. This function: + /// 1. Burns `payment.burn()` pUSD from the buyer (principal + interest) + /// 2. Transfers `payment.insurance_fund()` pUSD from buyer to Insurance Fund + /// 3. Releases `collateral_amount` from the vault owner's Seized hold + /// 4. Transfers the collateral to the recipient + /// 5. Reduces `CurrentLiquidationAmount` by `payment.total()` + /// + /// # Errors + /// + /// Returns an error if the buyer has insufficient pUSD or the collateral transfer fails. + fn execute_purchase( + buyer: &AccountId, + collateral_amount: Self::Balance, + payment: PaymentBreakdown, + recipient: &AccountId, + vault_owner: &AccountId, + ) -> DispatchResult; + + /// Complete an auction: pay keeper, return excess collateral, record any shortfall. + /// + /// Called when auction finishes (tab satisfied or lot exhausted). + /// + /// # Parameters + /// + /// - `vault_owner`: Original vault owner (receives excess collateral) + /// - `remaining_collateral`: Excess collateral to return to owner + /// - `remaining_debt`: Breakdown of unresolved debt (principal, interest, penalty). `principal + /// + interest` becomes bad debt; penalty is lost protocol revenue. `CurrentLiquidationAmount` + /// is decremented by `remaining_debt.total()`. + /// - `keeper`: Account that triggered/restarted the auction + /// - `keeper_incentive`: pUSD amount to pay keeper (from IF, funded by penalty) + /// + /// # Errors + /// + /// Returns an error if the keeper payment or collateral release fails. + fn complete_auction( + vault_owner: &AccountId, + remaining_collateral: Self::Balance, + remaining_debt: DebtComponents, + keeper: &AccountId, + keeper_incentive: Self::Balance, + ) -> DispatchResult; + + /// Execute a surplus auction purchase: buyer sends collateral, receives pUSD from IF. + /// + /// Called during `take_surplus()`. This function: + /// 1. Transfers `pusd_amount` pUSD from the Insurance Fund to the recipient + /// 2. Transfers `collateral_amount` from the buyer to the `FeeHandler` + /// + /// # Errors + /// + /// Returns an error if the buyer has insufficient collateral or IF has insufficient pUSD. + fn execute_surplus_purchase( + buyer: &AccountId, + recipient: &AccountId, + pusd_amount: Self::Balance, + collateral_amount: Self::Balance, + ) -> DispatchResult; + + /// Get the Insurance Fund's pUSD balance. + /// + /// Used to check if surplus auctions can be started (IF balance > threshold). + fn get_insurance_fund_balance() -> Self::Balance; + + /// Get the total pUSD supply. + /// + /// Used with `get_insurance_fund_balance()` to calculate whether the + /// Insurance Fund exceeds the surplus auction threshold. + fn get_total_pusd_supply() -> Self::Balance; + + /// Transfer surplus pUSD from Insurance Fund via configured handler. + /// + /// Used in DirectTransfer mode to send surplus directly to treasury + /// without going through an auction. The destination is determined + /// by the runtime's `SurplusHandler` configuration. + /// + /// # Parameters + /// - `amount`: Amount of pUSD to transfer from Insurance Fund + /// + /// # Errors + /// Returns an error if the Insurance Fund has insufficient pUSD. + fn transfer_surplus(amount: Self::Balance) -> DispatchResult; +} diff --git a/substrate/frame/vaults/Cargo.toml b/substrate/frame/vaults/Cargo.toml new file mode 100644 index 0000000000000..0abca370fc484 --- /dev/null +++ b/substrate/frame/vaults/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "pallet-vaults" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license = "Apache-2.0" +homepage.workspace = true +repository.workspace = true +description = "FRAME pallet for Vaults." +readme = "README.md" +include = ["README.md", "src/**/*"] + +[lints] +workspace = true + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { features = ["derive"], workspace = true } +frame-benchmarking = { workspace = true, optional = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +log = { workspace = true } +scale-info = { features = ["derive"], workspace = true } +sp-runtime = { workspace = true } +xcm = { workspace = true } + +[dev-dependencies] +pallet-assets = { workspace = true, default-features = true } +pallet-balances = { workspace = true, default-features = true } +sp-io = { workspace = true, default-features = true } + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "log/std", + "scale-info/std", + "sp-runtime/std", + "xcm/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-assets/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "xcm/runtime-benchmarks", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "pallet-assets/try-runtime", + "pallet-balances/try-runtime", + "sp-runtime/try-runtime", +] diff --git a/substrate/frame/vaults/README.md b/substrate/frame/vaults/README.md new file mode 100644 index 0000000000000..261eebb7ae2ed --- /dev/null +++ b/substrate/frame/vaults/README.md @@ -0,0 +1,320 @@ +# Vaults Pallet + +A Collateralized Debt Position (CDP) system for creating over-collateralized stablecoin loans on Substrate-based blockchains. + +## Overview + +The Vault pallet allows users to lock up collateral (DOT) and mint a stablecoin (pUSD) against it. +This creates a decentralized lending system where: + +- **Collateral is held**: Users deposit native tokens (DOT) which are held via `MutateHold` +- **Debt is minted**: Users can mint stablecoin (pUSD) up to a specified collateralization ratio +- **Interest accrues**: Vaults accumulate interest over time (stability fee) +- **Liquidation protects the system**: Under-collateralized vaults can be liquidated to maintain system solvency + +**Key Design Choice**: Each account can have at most one vault. Collateral is held in the user's +account (not transferred to a pallet account) using the `VaultDeposit` hold reason. + +## Vault Lifecycle + +### 1. Create Vault + +```rust +create_vault(origin, initial_deposit) +``` + +- Creates a new vault with an initial collateral deposit +- Requires `initial_deposit >= MinimumDeposit` +- Collateral is held via `MutateHold::hold()` with `VaultDeposit` reason +- Vault starts with zero debt + +### 2. Deposit More Collateral + +```rust +deposit_collateral(origin, amount) +``` + +- Add more collateral to improve collateralization ratio +- Triggers fee accrual before deposit +- Useful before minting more debt or avoiding liquidation + +### 3. Mint Stablecoin (Borrow) + +```rust +mint(origin, amount) +``` + +- Mint stablecoin (pUSD) against locked collateral +- Requires `amount >= MinimumMint` (prevents dust mints) +- Must maintain the Initial Collateralization Ratio (higher than minimum for safety buffer) +- Verifies oracle price is fresh (not older than `OracleStalenessThreshold`) +- System enforces `MaximumIssuance` debt ceiling + +### 4. Repay Debt + +```rust +repay(origin, amount) +``` + +- Burn stablecoin to reduce debt +- Payment order: interest first (transferred to Insurance Fund), then principal (burned) +- Excess amount beyond total debt is not consumed + +### 5. Withdraw Collateral + +```rust +withdraw_collateral(origin, amount) +``` + +- Release held collateral back to owner +- Must maintain Initial Collateralization Ratio if vault has debt +- Verifies oracle price is fresh when vault has debt +- Cannot create dust vaults (remaining collateral must be >= MinimumDeposit or zero) + +### 6. Close Vault + +```rust +close_vault(origin) +``` + +- Close a debt-free vault and release all collateral +- Requires zero debt (all loans repaid) +- Transfers any accrued interest to Insurance Fund before closing +- Removes vault from storage + +### 7. Poke (Force Fee Accrual) + +```rust +poke(origin, vault_owner) +``` + +- Permissionless extrinsic to force fee accrual on any vault +- Useful for keeping vault state fresh for accurate queries +- Cannot poke a vault that is `InLiquidation` + +### 8. Liquidation (Called by Keepers) + +```rust +liquidate_vault(origin, vault_owner) +``` + +- Anyone can liquidate vaults below Minimum Collateralization Ratio +- Verifies oracle price is fresh +- Calculates liquidation penalty on principal +- Changes hold reason from `VaultDeposit` to `Seized` +- Starts auction via `AuctionsHandler` + +**Note**: Protocol revenue comes solely from stability fees. Liquidation penalties incentivize +external keepers to monitor and liquidate risky vaults promptly. + +## Liquidation + +The pallet implements liquidation risk management via concurrent auction limits. + +### MaxLiquidationAmount and CurrentLiquidationAmount + +- **MaxLiquidationAmount**: Hard limit on pUSD at risk in active auctions (set via governance) +- **CurrentLiquidationAmount**: Current pUSD at risk across all active auctions (accumulator) + +When liquidating, the system checks if adding the new auction's debt would exceed +`MaxLiquidationAmount`. If so, the liquidation is blocked with `ExceedsMaxLiquidationAmount` error. + +This is a **hard limit** to prevent too much collateral being auctioned simultaneously, which could overwhelm market liquidity. + +### Auction Integration + +The Auctions pallet communicates back to Vaults via the `CollateralManager` trait: + +```rust +pub trait CollateralManager { + type Balance; + + /// Get current collateral price from oracle. + fn get_dot_price() -> Option; + + /// Execute a purchase: collect pUSD from buyer, transfer collateral to recipient. + fn execute_purchase(params: PurchaseParams) -> DispatchResult; + + /// Complete an auction: return excess collateral and record any shortfall. + fn complete_auction( + vault_owner: &AccountId, + remaining_collateral: Self::Balance, + shortfall: Self::Balance, + ) -> DispatchResult; +} +``` + +### Bad Debt + +When auctions can't cover the full debt, the shortfall is recorded as `BadDebt`. This represents +a system deficit that can be addressed via: + +```rust +heal(origin, amount) +``` + +- Permissionless extrinsic to burn pUSD from the Insurance Fund to reduce bad debt + +## Interest Accrual + +Vaults accumulate interest (stability fee) over time using timestamps: + +``` +Interest_pUSD = Principal × StabilityFee × (DeltaMillis / MillisPerYear) +``` + +Where: + +- `DeltaMillis = current_timestamp - last_fee_update` +- `MillisPerYear = 31,557,600,000` (365.25 days) + +Interest is stored in `accrued_interest` and transferred to the Insurance Fund during repayment or vault closure. + +### Stale Vault Housekeeping + +During `on_idle`, the pallet updates fees for stale vaults: + +- A vault is stale if `current_timestamp - last_fee_update >= StaleVaultThreshold` +- Uses cursor-based pagination across blocks +- Only updates `accrued_interest` - no transfers occur + +## Collateralization Ratio + +The system enforces two key ratios: + +1. **Initial Collateralization Ratio** (e.g., 200%) + - Required when minting new debt or withdrawing collateral + - Ensures adequate buffer for price volatility + +2. **Minimum Collateralization Ratio** (e.g., 180%) + - Liquidation threshold + - Vaults below this ratio can be liquidated + +**Formula**: + +``` +CR = (collateral_amount × oracle_price) / (principal + accrued_interest) +``` + +## Oracle Integration + +The pallet requires a price oracle that provides: + +- **Normalized price**: `smallest_pUSD_units / smallest_collateral_unit` +- **Timestamp**: When the price was last updated + +Operations are paused when the oracle price is older than `OracleStalenessThreshold`: + +- `mint` fails with `OracleStale` +- `withdraw_collateral` (with debt) fails with `OracleStale` +- `liquidate_vault` fails with `OracleStale` + +## Configuration + +The pallet requires the following configuration in the runtime: + +```rust +type VaultsAsset = frame_support::traits::fungible::ItemOf< + Assets, + StablecoinAssetId, + AccountId, +>; + +impl pallet_vaults::Config for Runtime { + type Currency = Balances; // Native token for collateral (MutateHold) + type RuntimeHoldReason = RuntimeHoldReason; // Hold reason enum + type Asset = VaultsAsset; // pUSD via a single-asset ItemOf adapter + type InsuranceFund = InsuranceFund; // Account receiving protocol revenue + type MinimumDeposit = MinimumDeposit; // Min collateral to create vault + type MinimumMint = MinimumMint; // Min pUSD per mint operation + type TimeProvider = Timestamp; // For timestamp-based fees + type StaleVaultThreshold = StaleVaultThreshold; // When vaults need on_idle update + type OracleStalenessThreshold = OracleStalenessThreshold; // Max oracle price age + type Oracle = NudgeAggregator; // Price oracle (ProvidePrice trait) + type CollateralLocation = CollateralLocation; // Location for oracle queries + type AuctionsHandler = Auctions; // Liquidation handler + type ManagerOrigin = EnsureVaultsManager; // Governance origin (returns privilege level) + type WeightInfo = weights::SubstrateWeight; +} +``` + +### Required Constants + +- `StablecoinAssetId`: Runtime-local asset ID getter used to bind `Assets` into `ItemOf` +- `InsuranceFund`: Account that receives collected interest and penalties +- `MinimumDeposit`: Minimum DOT to create a vault (prevents dust) +- `MinimumMint`: Minimum pUSD per mint (prevents dust) +- `StaleVaultThreshold`: Milliseconds before vault is considered stale (default: 4 hours) +- `OracleStalenessThreshold`: Max oracle price age before operations pause (default: 1 hour) +- `CollateralLocation`: XCM Location identifying collateral for oracle + +### Parameters (Set via Governance) + +| Parameter | Description | Example | +| ------------------------------- | -------------------------------- | -------- | +| `MinimumCollateralizationRatio` | Liquidation threshold | 180% | +| `InitialCollateralizationRatio` | Required for minting/withdrawing | 200% | +| `StabilityFee` | Annual interest rate | 4% | +| `LiquidationPenalty` | Penalty on liquidated principal | 13% | +| `MaximumIssuance` | System-wide debt ceiling | 20M pUSD | +| `MaxLiquidationAmount` | Max pUSD at risk in auctions | 20M pUSD | + +### Privilege Levels + +The `ManagerOrigin` returns a privilege level: + +- **Full** (via GeneralAdmin): Can modify all parameters +- **Emergency** (via EmergencyAction): Can only lower debt ceiling (defensive action) + +## Events + +- `VaultCreated { owner }`: New vault created +- `CollateralDeposited { owner, amount }`: Collateral added to vault +- `CollateralWithdrawn { owner, amount }`: Collateral removed from vault +- `Minted { owner, amount }`: Stablecoin minted (debt increased) +- `Repaid { owner, amount }`: Principal repaid and burned +- `ReturnedExcess { owner, amount }`: Excess pUSD when repayment exceeded debt +- `InterestAccrued { owner, amount }`: Interest accrued (during fee update) +- `VaultClosed { owner }`: Vault closed and removed +- `InLiquidation { owner, debt, collateral_seized }`: Vault liquidated +- `LiquidationPenaltyAdded { owner, amount }`: Liquidation penalty applied +- `AuctionStarted { owner, auction_id, collateral, tab }`: Auction initiated +- `AuctionDebtCollected { amount }`: pUSD collected from auction +- `AuctionShortfall { shortfall }`: Auction couldn't cover debt +- `BadDebtAccrued { owner, amount }`: Debt exceeded collateral +- `BadDebtRepaid { amount }`: Bad debt reduced via heal +- `MinimumCollateralizationRatioUpdated { old_value, new_value }` +- `InitialCollateralizationRatioUpdated { old_value, new_value }` +- `StabilityFeeUpdated { old_value, new_value }` +- `LiquidationPenaltyUpdated { old_value, new_value }` +- `MaximumIssuanceUpdated { old_value, new_value }` +- `MaxLiquidationAmountUpdated { old_value, new_value }` + +## Errors + +- `VaultNotFound`: No vault exists for the account +- `VaultAlreadyExists`: Account already has a vault +- `VaultHasDebt`: Cannot close vault with outstanding debt +- `VaultIsSafe`: Cannot liquidate a healthy vault +- `VaultInLiquidation`: Vault is in liquidation; operations blocked +- `InsufficientCollateral`: Not enough collateral for operation +- `UnsafeCollateralizationRatio`: Operation would breach required CR +- `ExceedsMaxDebt`: Minting would exceed system debt ceiling +- `ExceedsMaxPositionAmount`: Minting would exceed maximum single vault debt +- `ExceedsMaxLiquidationAmount`: Liquidation would exceed auction limit +- `BelowMinimumDeposit`: Deposit or remaining collateral too small +- `BelowMinimumMint`: Mint amount below minimum +- `PriceNotAvailable`: Oracle returned no price +- `OracleStale`: Oracle price is too old +- `ArithmeticOverflow`: Calculation overflow +- `InsufficientPrivilege`: Emergency tried Full-only operation +- `CanOnlyLowerMaxDebt`: Emergency tried to raise debt ceiling +- `InitialRatioMustExceedMinimum`: ICR must be >= MCR + +## Testing + +Run tests with: + +```bash +SKIP_WASM_BUILD=1 cargo test -p pallet-vaults +``` diff --git a/substrate/frame/vaults/src/benchmarking.rs b/substrate/frame/vaults/src/benchmarking.rs new file mode 100644 index 0000000000000..bdcc25702f97b --- /dev/null +++ b/substrate/frame/vaults/src/benchmarking.rs @@ -0,0 +1,634 @@ +// This file is part of Substrate. + +// Copyright (C) Amforc AG. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Benchmarking setup for pallet-vaults + +use super::*; +use crate::{ + BadDebt, InitialCollateralizationRatio, LiquidationPenalty, MaxLiquidationAmount, + MaxPositionAmount, MaximumIssuance, MinimumCollateralizationRatio, MinimumDeposit, MinimumMint, + OnIdleCursor, OracleStalenessThreshold, Pallet as Vaults, StabilityFee, StaleVaultThreshold, + VaultStatus, Vaults as VaultsStorage, +}; +use frame_benchmarking::v2::*; +use frame_support::{ + traits::{fungible::Inspect, Get, Hooks}, + weights::Weight, +}; +use frame_system::RawOrigin; +use pallet::{BalanceOf, MomentOf}; +use sp_runtime::{FixedU128, Permill, SaturatedConversion, Saturating}; + +/// A larger deposit for scenarios requiring extra collateral headroom +fn large_deposit() -> BalanceOf { + // 10x minimum to allow minting and withdrawals + MinimumDeposit::::get() + .expect("set in genesis; qed") + .saturating_mul(10u32.into()) +} + +/// Safe mint amount that maintains ICR with large_deposit. +/// +/// The multiplier (400×) is chosen so the result stays well under the ICR +/// ceiling with `large_deposit` at typical oracle prices. +fn safe_mint_amount() -> BalanceOf { + MinimumMint::::get() + .expect("set in genesis; qed") + .saturating_mul(400u32.into()) +} + +/// Fund an account with collateral (DOT) +fn fund_account(account: &T::AccountId, amount: BalanceOf) { + use frame_support::traits::fungible::Mutate; + let _ = frame_system::Pallet::::inc_providers(account); + let _ = >::mint_into(account, amount); +} + +/// Ensure the InsuranceFund account can receive funds +fn ensure_insurance_fund() { + let insurance_fund = T::InsuranceFund::get(); + if !frame_system::Pallet::::account_exists(&insurance_fund) { + frame_system::Pallet::::inc_providers(&insurance_fund); + } + fund_account::(&insurance_fund, MinimumDeposit::::get().expect("set in genesis; qed")); +} + +/// Ensure the stablecoin asset exists +fn ensure_stablecoin_asset() { + T::BenchmarkHelper::create_stablecoin_asset(); +} + +/// Set up the system for minting by ensuring MaximumIssuance is set high enough +fn ensure_can_mint(amount: BalanceOf) { + let current_issuance = T::StableAsset::total_issuance(); + let required = current_issuance.saturating_add(amount).saturating_mul(2u32.into()); + let current_max = MaximumIssuance::::get().unwrap_or_default(); + if current_max < required { + MaximumIssuance::::put(required); + } +} + +/// Ensure MaxPositionAmount is set high enough for the given mint amount +fn ensure_max_position_amount(amount: BalanceOf) { + let required = amount.saturating_mul(2u32.into()); + let current_max = MaxPositionAmount::::get().unwrap_or_default(); + if current_max < required { + MaxPositionAmount::::put(required); + } +} + +/// Mint pUSD to an account (for repay scenarios) +fn mint_pusd_to( + account: &T::AccountId, + amount: BalanceOf, +) -> Result<(), BenchmarkError> { + use frame_support::traits::fungible::Mutate; + ensure_stablecoin_asset::(); + let _ = >::mint_into(account, amount); + Ok(()) +} + +/// Create a vault with collateral for the given account +fn create_vault_for( + owner: &T::AccountId, + deposit: BalanceOf, +) -> Result<(), BenchmarkError> { + fund_account::(owner, deposit.saturating_mul(2u32.into())); + Vaults::::create_vault(RawOrigin::Signed(owner.clone()).into(), deposit) + .map_err(|_| BenchmarkError::Stop("Failed to create vault"))?; + Ok(()) +} + +/// Create a vault with debt for the given account +fn create_vault_with_debt(owner: &T::AccountId) -> Result, BenchmarkError> { + let deposit = large_deposit::(); + let mint_amount = safe_mint_amount::(); + + ensure_stablecoin_asset::(); + ensure_can_mint::(mint_amount); + ensure_max_position_amount::(mint_amount); + + create_vault_for::(owner, deposit)?; + + Vaults::::mint(RawOrigin::Signed(owner.clone()).into(), mint_amount).map_err(|e| { + log::error!( + target: "runtime::vaults::benchmark", + "Failed to mint in vault: {:?}", + e + ); + BenchmarkError::Stop("Failed to mint in vault") + })?; + + Ok(mint_amount) +} + +/// Advance timestamp to trigger fee accrual. +/// +/// For worst-case benchmarking, we advance by `StaleVaultThreshold` milliseconds. +/// This represents the realistic maximum time a vault could go without +/// fee updates, since `on_idle` processes vaults that exceed this threshold. +fn advance_to_stale_threshold() { + let stale_threshold: u64 = + StaleVaultThreshold::::get().expect("set in genesis; qed").saturated_into(); + T::BenchmarkHelper::advance_time(stale_threshold + 1); +} + +#[benchmarks] +mod benchmarks { + use super::*; + + // ============================================ + // User Operations + // ============================================ + + /// Benchmark: create_vault + /// Creates a new vault with initial collateral deposit. + #[benchmark] + fn create_vault() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + let deposit = MinimumDeposit::::get().expect("set in genesis; qed"); + + // Fund account with enough balance + fund_account::(&caller, deposit.saturating_mul(2u32.into())); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), deposit); + + // Verify vault was created + assert!(VaultsStorage::::contains_key(&caller)); + Ok(()) + } + + /// Benchmark: deposit_collateral + /// Deposits additional collateral into an existing vault. + /// Worst case: vault at StaleVaultThreshold blocks since last update, + /// triggering maximum realistic fee accrual computation. + #[benchmark] + fn deposit_collateral() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + + // Create vault with debt so fee accrual has work to do + create_vault_with_debt::(&caller)?; + + // Advance to stale threshold (worst case - just before on_idle would process) + advance_to_stale_threshold::(); + + // Fund additional collateral + let additional = MinimumDeposit::::get().expect("set in genesis; qed"); + fund_account::(&caller, additional.saturating_mul(2u32.into())); + + let collateral_before = VaultsStorage::::get(&caller) + .ok_or(BenchmarkError::Stop("Vault not found"))? + .get_held_collateral(&caller); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), additional); + + // Verify collateral increased + let collateral_after = VaultsStorage::::get(&caller) + .ok_or(BenchmarkError::Stop("Vault not found"))? + .get_held_collateral(&caller); + assert!(collateral_after > collateral_before); + Ok(()) + } + + /// Benchmark: withdraw_collateral + /// Withdraws collateral from a vault. + /// Worst case: vault with debt at StaleVaultThreshold, triggering fee accrual + /// (minting to InsuranceFund) and CR validation (oracle + ratio check). + #[benchmark] + fn withdraw_collateral() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + ensure_insurance_fund::(); + + // Create vault with debt so fee accrual and CR check are exercised + create_vault_with_debt::(&caller)?; + + // Deposit extra collateral to create headroom above ICR, so the vault + // can survive both fee accrual and a small withdrawal without breaching ICR. + let extra = large_deposit::(); + fund_account::(&caller, extra.saturating_mul(2u32.into())); + Vaults::::deposit_collateral(RawOrigin::Signed(caller.clone()).into(), extra) + .map_err(|_| BenchmarkError::Stop("Failed to deposit extra collateral"))?; + + // Advance to stale threshold (worst case - maximum realistic fee accrual) + advance_to_stale_threshold::(); + + // Withdraw MinimumDeposit — small enough to keep CR above ICR + let withdraw_amount = MinimumDeposit::::get().expect("set in genesis; qed"); + let collateral_before = VaultsStorage::::get(&caller) + .ok_or(BenchmarkError::Stop("Vault not found"))? + .get_held_collateral(&caller); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), withdraw_amount); + + // Verify collateral decreased + let collateral_after = VaultsStorage::::get(&caller) + .ok_or(BenchmarkError::Stop("Vault not found"))? + .get_held_collateral(&caller); + assert!(collateral_after < collateral_before); + Ok(()) + } + + /// Benchmark: mint + /// Mints stablecoin against vault collateral. + /// Worst case: vault with existing debt at StaleVaultThreshold, triggering fee + /// accrual (minting to InsuranceFund), CR validation, and max debt check. + #[benchmark] + fn mint() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + ensure_insurance_fund::(); + + // Create vault with existing debt so fee accrual triggers minting + let first_mint = create_vault_with_debt::(&caller)?; + + // Deposit extra collateral to create headroom above ICR, so the vault + // can survive fee accrual and a second mint without breaching ICR. + let extra = large_deposit::().saturating_mul(2u32.into()); + fund_account::(&caller, extra.saturating_mul(2u32.into())); + Vaults::::deposit_collateral(RawOrigin::Signed(caller.clone()).into(), extra) + .map_err(|_| BenchmarkError::Stop("Failed to deposit extra collateral"))?; + + // Advance to stale threshold (worst case - maximum realistic fee accrual) + advance_to_stale_threshold::(); + + // Mint a second amount on top of existing debt + let mint_amount = safe_mint_amount::(); + ensure_can_mint::(mint_amount.saturating_mul(3u32.into())); + ensure_max_position_amount::(first_mint.saturating_add(mint_amount)); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), mint_amount); + + // Verify debt increased beyond the first mint + let vault = + VaultsStorage::::get(&caller).ok_or(BenchmarkError::Stop("Vault not found"))?; + assert!(vault.principal > first_mint); + Ok(()) + } + + /// Benchmark: repay + /// Repays stablecoin debt. + /// Worst case: vault at StaleVaultThreshold with accrued interest, + /// interest payment to InsuranceFund, pUSD burn. + #[benchmark] + fn repay() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + ensure_insurance_fund::(); + + // Create vault with debt + let debt = create_vault_with_debt::(&caller)?; + + // Advance to stale threshold to accrue interest (worst case) + advance_to_stale_threshold::(); + + // Caller already has pUSD from minting, repay half + let repay_amount = debt / 2u32.into(); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), repay_amount); + + // Verify debt decreased + let vault = + VaultsStorage::::get(&caller).ok_or(BenchmarkError::Stop("Vault not found"))?; + assert!(vault.principal < debt); + Ok(()) + } + + /// Benchmark: liquidate_vault + /// Liquidates an undercollateralized vault. + /// Worst case: vault at StaleVaultThreshold with accrued fees, + /// penalty calculation, auction start. + #[benchmark] + fn liquidate_vault() -> Result<(), BenchmarkError> { + // Create a vault owner (victim) and a liquidator (keeper) + let vault_owner: T::AccountId = account("vault_owner", 0, 0); + let keeper: T::AccountId = whitelisted_caller(); + + // Create vault with debt at normal price + create_vault_with_debt::(&vault_owner)?; + + // Advance to stale threshold (worst case) + advance_to_stale_threshold::(); + + // Crash the price to make vault undercollateralized + // With large_deposit (1000 DOLLARS) and safe_mint_amount (2000 pUSD): + // - collateral_value = large_deposit * price / 10^18 + // - For CR < 180% (MCR): collateral_value < 2000 * 1.8 = 3600 pUSD + // - Need: large_deposit * price / 10^18 < 3600 * 10^6 + // - With large_deposit = 10^17: price < 36 * 10^9 + // Set price to 1_000_000_000 (very low) to ensure CR << MCR + T::BenchmarkHelper::set_price(FixedU128::from_inner(1_000_000_000)); + + #[extrinsic_call] + _(RawOrigin::Signed(keeper), vault_owner.clone()); + + // Verify vault is in liquidation + let vault = + VaultsStorage::::get(&vault_owner).ok_or(BenchmarkError::Stop("Vault not found"))?; + assert_eq!(vault.status, VaultStatus::InLiquidation); + + // Reset price for other tests (normalized $4.21) + T::BenchmarkHelper::set_price(FixedU128::from_inner(421_000_000_000_000)); + + Ok(()) + } + + /// Benchmark: close_vault + /// Closes a debt-free vault and returns all collateral. + /// Worst case: vault at StaleVaultThreshold, fee path traversal, collateral release. + #[benchmark] + fn close_vault() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + ensure_insurance_fund::(); + + // Create vault without debt (just collateral) + let deposit = large_deposit::(); + create_vault_for::(&caller, deposit)?; + + // Advance to stale threshold (worst case - tests fee path even without debt) + advance_to_stale_threshold::(); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone())); + + // Verify vault was removed + assert!(!VaultsStorage::::contains_key(&caller)); + Ok(()) + } + + /// Benchmark: heal + /// Repays bad debt by burning pUSD from InsuranceFund. + #[benchmark] + fn heal() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + ensure_insurance_fund::(); + + // Set up bad debt + let bad_debt_amount: BalanceOf = safe_mint_amount::(); + BadDebt::::put(bad_debt_amount); + + // Mint pUSD to InsuranceFund so it can be burned + mint_pusd_to::(&T::InsuranceFund::get(), bad_debt_amount)?; + + let heal_amount = bad_debt_amount / 2u32.into(); + + #[extrinsic_call] + _(RawOrigin::Signed(caller), heal_amount); + + // Verify bad debt reduced + let remaining_bad_debt = BadDebt::::get(); + assert!(remaining_bad_debt < bad_debt_amount); + Ok(()) + } + + /// Benchmark: poke + /// Forces fee accrual on any vault. + /// Worst case: vault at StaleVaultThreshold, maximum realistic fee calculation. + #[benchmark] + fn poke() -> Result<(), BenchmarkError> { + let vault_owner: T::AccountId = account("vault_owner", 0, 0); + let caller: T::AccountId = whitelisted_caller(); + + // Create vault with debt (so fee accrual has work to do) + create_vault_with_debt::(&vault_owner)?; + + // Advance to stale threshold (worst case - just before on_idle would process) + advance_to_stale_threshold::(); + + let vault_before = + VaultsStorage::::get(&vault_owner).ok_or(BenchmarkError::Stop("Vault not found"))?; + let last_update_before = vault_before.last_fee_update; + + #[extrinsic_call] + _(RawOrigin::Signed(caller), vault_owner.clone()); + + // Verify last_fee_update was updated + let vault_after = + VaultsStorage::::get(&vault_owner).ok_or(BenchmarkError::Stop("Vault not found"))?; + assert!(vault_after.last_fee_update > last_update_before); + Ok(()) + } + + // ============================================ + // Governance Operations (Root origin) + // ============================================ + + /// Benchmark: set_minimum_collateralization_ratio + #[benchmark] + fn set_minimum_collateralization_ratio() -> Result<(), BenchmarkError> { + let new_ratio = FixedU128::from_rational(140, 100); // 140% + + #[extrinsic_call] + _(RawOrigin::Root, new_ratio); + + // Verify ratio was updated + assert_eq!(MinimumCollateralizationRatio::::get(), Some(new_ratio)); + Ok(()) + } + + /// Benchmark: set_initial_collateralization_ratio + #[benchmark] + fn set_initial_collateralization_ratio() -> Result<(), BenchmarkError> { + let new_ratio = FixedU128::from_rational(220, 100); // 220% + + #[extrinsic_call] + _(RawOrigin::Root, new_ratio); + + // Verify ratio was updated + assert_eq!(InitialCollateralizationRatio::::get(), Some(new_ratio)); + Ok(()) + } + + /// Benchmark: set_stability_fee + #[benchmark] + fn set_stability_fee() -> Result<(), BenchmarkError> { + let new_fee = Permill::from_percent(5); // 5% + + #[extrinsic_call] + _(RawOrigin::Root, new_fee); + + // Verify fee was updated + assert_eq!(StabilityFee::::get(), Some(new_fee)); + Ok(()) + } + + /// Benchmark: set_liquidation_penalty + #[benchmark] + fn set_liquidation_penalty() -> Result<(), BenchmarkError> { + let new_penalty = Permill::from_percent(15); // 15% + + #[extrinsic_call] + _(RawOrigin::Root, new_penalty); + + // Verify penalty was updated + assert_eq!(LiquidationPenalty::::get(), Some(new_penalty)); + Ok(()) + } + + /// Benchmark: set_max_liquidation_amount + #[benchmark] + fn set_max_liquidation_amount() -> Result<(), BenchmarkError> { + // Must be >= MaxPositionAmount to satisfy the invariant + let max_position = MaxPositionAmount::::get().unwrap_or_default(); + let new_amount = max_position.max(safe_mint_amount::().saturating_mul(1000u32.into())); + + #[extrinsic_call] + _(RawOrigin::Root, new_amount); + + // Verify amount was updated + assert_eq!(MaxLiquidationAmount::::get(), Some(new_amount)); + Ok(()) + } + + /// Benchmark: set_max_issuance + /// Tests with Full privilege level (can raise or lower). + #[benchmark] + fn set_max_issuance() -> Result<(), BenchmarkError> { + let new_amount: BalanceOf = safe_mint_amount::().saturating_mul(10000u32.into()); + + #[extrinsic_call] + _(RawOrigin::Root, new_amount); + + // Verify amount was updated + assert_eq!(MaximumIssuance::::get(), Some(new_amount)); + Ok(()) + } + + /// Benchmark: set_minimum_deposit + #[benchmark] + fn set_minimum_deposit() -> Result<(), BenchmarkError> { + let new_value: BalanceOf = MinimumDeposit::::get() + .expect("set in genesis; qed") + .saturating_mul(2u32.into()); + + #[extrinsic_call] + _(RawOrigin::Root, new_value); + + // Verify value was updated + assert_eq!(MinimumDeposit::::get(), Some(new_value)); + Ok(()) + } + + /// Benchmark: set_minimum_mint + #[benchmark] + fn set_minimum_mint() -> Result<(), BenchmarkError> { + let new_value: BalanceOf = 10_000_000u128.try_into().unwrap_or_else(|_| 1u32.into()); + + #[extrinsic_call] + _(RawOrigin::Root, new_value); + + // Verify value was updated + assert_eq!(MinimumMint::::get(), Some(new_value)); + Ok(()) + } + + /// Benchmark: set_stale_vault_threshold + #[benchmark] + fn set_stale_vault_threshold() -> Result<(), BenchmarkError> { + // 8 hours in milliseconds + let new_value: MomentOf = 28_800_000u64.try_into().unwrap_or_else(|_| 0u32.into()); + + #[extrinsic_call] + _(RawOrigin::Root, new_value); + + // Verify value was updated + assert_eq!(StaleVaultThreshold::::get(), Some(new_value)); + Ok(()) + } + + /// Benchmark: set_oracle_staleness_threshold + #[benchmark] + fn set_oracle_staleness_threshold() -> Result<(), BenchmarkError> { + // 2 hours in milliseconds + let new_value: MomentOf = 7_200_000u64.try_into().unwrap_or_else(|_| 0u32.into()); + + #[extrinsic_call] + _(RawOrigin::Root, new_value); + + // Verify value was updated + assert_eq!(OracleStalenessThreshold::::get(), Some(new_value)); + Ok(()) + } + + /// Benchmark: set_max_position_amount + #[benchmark] + fn set_max_position_amount() -> Result<(), BenchmarkError> { + // Must be <= MaxLiquidationAmount to satisfy the invariant + let max_liq = MaxLiquidationAmount::::get().unwrap_or_default(); + let new_amount = max_liq.min(safe_mint_amount::().saturating_mul(100u32.into())); + + #[extrinsic_call] + _(RawOrigin::Root, new_amount); + + // Verify amount was updated + assert_eq!(MaxPositionAmount::::get(), Some(new_amount)); + Ok(()) + } + + // ============================================ + // Hooks + // ============================================ + + /// Benchmark: on_idle processing a single stale vault with debt. + /// + /// This measures the worst-case per-vault cost in `on_idle`: + /// - Vault has debt (fee calculation required) + /// - Vault is stale (at StaleVaultThreshold) + /// - Fee accrual computation and storage write + /// + /// The resulting weight is used by `on_idle` to determine how many + /// vaults can be processed within the available weight budget. + #[benchmark] + fn on_idle_one_vault() -> Result<(), BenchmarkError> { + let vault_owner: T::AccountId = account("vault_owner", 0, 0); + + // Create vault with debt (worst case - fee calculation required) + create_vault_with_debt::(&vault_owner)?; + + // Advance to stale threshold so vault will be processed + advance_to_stale_threshold::(); + + // Clear cursor to start fresh iteration + OnIdleCursor::::kill(); + + let vault_before = + VaultsStorage::::get(&vault_owner).ok_or(BenchmarkError::Stop("Vault not found"))?; + let last_update_before = vault_before.last_fee_update; + + let current_block = frame_system::Pallet::::block_number(); + + #[block] + { + // Process with unlimited weight - will process exactly one vault + Vaults::::on_idle(current_block, Weight::MAX); + } + + // Verify vault was processed (last_fee_update timestamp changed) + let vault_after = + VaultsStorage::::get(&vault_owner).ok_or(BenchmarkError::Stop("Vault not found"))?; + assert!( + vault_after.last_fee_update > last_update_before, + "last_fee_update should be updated" + ); + + Ok(()) + } + + impl_benchmark_test_suite!(Vaults, crate::mock::new_test_ext(), crate::mock::Test); +} diff --git a/substrate/frame/vaults/src/lib.rs b/substrate/frame/vaults/src/lib.rs new file mode 100644 index 0000000000000..1d486d7437849 --- /dev/null +++ b/substrate/frame/vaults/src/lib.rs @@ -0,0 +1,2630 @@ +// This file is part of Substrate. + +// Copyright (C) Amforc AG. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! # Vaults Pallet +//! +//! A Collateralized Debt Position (CDP) system for minting over-collateralized stablecoins. +//! +//! ## Pallet API +//! +//! See the [`pallet`] module for more information about the interfaces this pallet exposes, +//! including its configuration trait, dispatchables, storage items, events and errors. +//! +//! ## Overview +//! +//! The Vaults pallet serves as the CDP engine for the pUSD protocol, enabling users to reserve +//! DOT as collateral to mint pUSD stablecoins. It includes risk management tools to help the +//! system stay well-collateralized, including liquidation mechanisms and emergency controls. +//! +//! ### Key Concepts +//! +//! * **[`Vault`]**: A per-account structure tracking collateralized debt. Each account can have at +//! most one vault. Stores principal, `accrued_interest`, status, and `last_fee_update` timestamp. +//! +//! * **Collateral**: DOT held via [`MutateHold`](frame::traits::fungible::MutateHold) with the +//! [`HoldReason::VaultDeposit`] reason. The pallet does not transfer funds to a pallet account; +//! collateral stays in the user's account. +//! +//! * **Principal**: The pUSD debt excluding accrued interest. +//! +//! * **Accrued Interest**: Stability fees accumulated over time, calculated using [`StabilityFee`]. +//! +//! * **Collateralization Ratio**: `CR = (Collateral × Price) / (Principal + AccruedInterest)`. Two +//! ratios are enforced: +//! - **Initial CR** ([`InitialCollateralizationRatio`]): Required when minting or withdrawing +//! - **Minimum CR** ([`MinimumCollateralizationRatio`]): Liquidation threshold +//! +//! * **Insurance Fund**: An account ([`Config::InsuranceFund`]) that receives protocol revenue and +//! serves as a backstop against bad debt. +//! +//! * **Bad Debt**: Unbacked pUSD (principal + interest) recorded in [`BadDebt`] when liquidation +//! auctions fail to cover vault debt. Unpaid penalty is lost protocol revenue, not bad debt. Can +//! be healed via [`Pallet::heal`]. +//! +//! ### Vault Lifecycle +//! +//! 1. **Create**: User deposits DOT (≥ [`Config::MinimumDeposit`]) via [`Pallet::create_vault`] +//! 2. **Mint**: User mints pUSD via [`Pallet::mint`], maintaining Initial CR +//! 3. **Repay**: User burns pUSD via [`Pallet::repay`]; interest goes to Insurance Fund +//! 4. **Withdraw**: User releases collateral via [`Pallet::withdraw_collateral`] +//! 5. **Close**: User closes debt-free vault via [`Pallet::close_vault`] +//! 6. **Liquidate**: Anyone can liquidate unsafe vaults via [`Pallet::liquidate_vault`] +//! +//! ### Hold Reasons +//! +//! The pallet uses two hold reasons for collateral management: +//! +//! * **[`HoldReason::VaultDeposit`]**: Collateral backing an active vault. Users can add/remove +//! collateral while maintaining required ratios. +//! +//! * **[`HoldReason::Seized`]**: Collateral seized during liquidation, pending auction. The auction +//! pallet operates on funds held with this reason. +//! +//! ### Example +//! +//! The following example demonstrates a typical vault lifecycle: +//! +//! ```ignore +//! // 1. Create a vault with initial collateral +//! Vaults::create_vault(RuntimeOrigin::signed(user), 100 * UNIT)?; +//! +//! // 2. Mint stablecoins against the collateral +//! Vaults::mint(RuntimeOrigin::signed(user), 20 * UNIT)?; +//! +//! // 3. Repay debt over time +//! Vaults::repay(RuntimeOrigin::signed(user), 20 * UNIT)?; +//! +//! // 4. Close the vault and withdraw all collateral +//! Vaults::close_vault(RuntimeOrigin::signed(user))?; +//! ``` +//! +//! For more detailed examples, see the integration tests in the `tests` module. +//! +//! ## Low Level / Implementation Details +//! +//! ### Oracle Integration +//! +//! The pallet requires a price oracle implementing [`ProvidePrice`] that returns: +//! - **Normalized price**: `smallest_pUSD_units / smallest_collateral_unit` +//! - **Timestamp**: When the price was last updated +//! +//! Operations requiring price data fail with [`Error::OracleStale`] if the price is older than +//! [`Config::OracleStalenessThreshold`] (default: 1 hour). +//! +//! ### Fee Calculation +//! +//! Interest accrues continuously based on elapsed milliseconds: +//! ```text +//! Interest = Principal × StabilityFee × (DeltaMillis / 31,557,600,000) +//! ``` +//! +//! Fees are updated lazily on vault interactions. Additionally: +//! - [`Pallet::poke`] allows anyone to force fee accrual on any vault +//! - `on_idle` updates stale vaults (unchanged for ≥ [`Config::StaleVaultThreshold`]) +//! +//! ### Liquidation Flow +//! +//! When a vault's CR falls below [`MinimumCollateralizationRatio`]: +//! +//! 1. Keeper calls [`Pallet::liquidate_vault`] +//! 2. Fees are updated and CR verified below minimum +//! 3. Liquidation penalty is calculated: `Penalty = Principal × LiquidationPenalty` +//! 4. Hold reason changes from [`HoldReason::VaultDeposit`] to [`HoldReason::Seized`] +//! 5. [`AuctionsHandler::start_auction`] is called with collateral and debt details +//! 6. Auction pallet calls back via [`CollateralManager`] trait for purchases +//! 7. On completion, excess collateral returns to owner; shortfall becomes bad debt +//! +//! ### Liquidation Limits +//! +//! [`MaxLiquidationAmount`] is a **hard limit** on auction exposure. +//! Liquidations are blocked when [`CurrentLiquidationAmount`] + `total_debt` > +//! [`MaxLiquidationAmount`], where `total_debt = principal + interest + penalty`. +//! +//! ### Governance Model +//! +//! The pallet supports tiered authorization via [`Config::ManagerOrigin`]: +//! +//! * **Full** ([`VaultsManagerLevel::Full`]): Can modify all parameters, raise or lower debt +//! ceiling +//! * **Emergency** ([`VaultsManagerLevel::Emergency`]): Can only lower debt ceiling (defensive +//! action) +//! +//! This enables fast-track emergency response to oracle attacks without full governance. +//! +//! ### External Traits +//! +//! The pallet implements [`CollateralManager`] for the auction pallet to: +//! - Query oracle prices via [`CollateralManager::get_dot_price`] +//! - Execute purchases via [`CollateralManager::execute_purchase`] +//! - Complete auctions via [`CollateralManager::complete_auction`] +//! +//! This design keeps all asset operations centralized in the vaults pallet while +//! allowing the auction logic to remain reusable for other collateral sources. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +use sp_runtime::FixedU128; +use xcm::latest::Location; + +pub mod migrations; +pub mod weights; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +pub use pallet::*; +pub use weights::WeightInfo; + +// Re-exports for external consumers +pub use frame_support::traits::{ + AuctionsHandler, CollateralManager, DebtComponents, PaymentBreakdown, +}; + +/// TODO: Update/import this trait from the Oracle as soon as it is implemented. +/// Trait for providing timestamped asset prices via oracle. +/// +/// This trait abstracts the oracle interface for getting asset prices with their +/// last update timestamp. The price must be in "normalized" format: +/// smallest pUSD units per smallest asset unit. +/// +/// # Example +/// For DOT at $4.21 with DOT (10 decimals) and pUSD (6 decimals): +/// - 1 DOT = 4.21 USD +/// - Price = 4.21 × 10^6 / 10^10 = 0.000421 +/// +/// Assets are identified by XCM `Location`, which can represent: +/// - Native token: `Location::here()` (DOT from AH perspective) +/// - Local assets: `Location::new(0, [PalletInstance(50), GeneralIndex(id)])` +/// +/// The timestamp allows consumers to check for oracle staleness and pause +/// operations when the price data is too old. +pub trait ProvidePrice { + /// The moment/timestamp type. + type Moment; + + /// Get the current price and timestamp when it was last updated. + /// + /// Returns `None` if the price is not available. + /// The tuple contains (price, `last_update_timestamp`). + fn get_price(asset: &Location) -> Option<(FixedU128, Self::Moment)>; +} + +#[frame_support::pallet] +pub mod pallet { + use super::{ + AuctionsHandler, CollateralManager, DebtComponents, PaymentBreakdown, ProvidePrice, + }; + use crate::WeightInfo; + + use frame_support::{ + pallet_prelude::*, + traits::{ + fungible::{ + Balanced as FungibleBalanced, Credit, Inspect, InspectHold, + Mutate as FungibleMutate, MutateHold, + }, + tokens::{imbalance::OnUnbalanced, Fortitude, Precision, Preservation, Restriction}, + DefensiveSaturating, Time, + }, + transactional, + weights::WeightMeter, + DefaultNoBound, + }; + use frame_system::pallet_prelude::*; + use sp_runtime::{ + traits::{Bounded, CheckedSub, Zero}, + FixedPointNumber, FixedPointOperand, FixedU128, Permill, SaturatedConversion, Saturating, + }; + use xcm::latest::Location; + + /// Log target for this pallet. + pub(crate) const LOG_TARGET: &str = "runtime::vaults"; + + /// Milliseconds per year for timestamp-based fee calculations. + const MILLIS_PER_YEAR: u64 = (365 * 24 + 6) * 60 * 60 * 1000; + + /// The reason for this pallet placing a hold on funds. + #[pallet::composite_enum] + pub enum HoldReason { + /// The funds are held as collateral in an active vault. + #[codec(index = 0)] + VaultDeposit, + /// The funds have been seized during liquidation and are pending auction. + /// The auction pallet operates on funds held with this reason. + #[codec(index = 1)] + Seized, + } + + /// Status of a vault in its lifecycle. + #[derive( + Encode, Decode, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq, Debug, Default, + )] + pub enum VaultStatus { + /// Vault is active and healthy. + #[default] + Healthy, + /// Vault has been liquidated and collateral is being auctioned. + /// No operations are allowed on the vault in this state. + InLiquidation, + } + + /// Privilege level returned by `ManagerOrigin`. + /// + /// This enables tiered authorization where different origins have different + /// capabilities for managing vault parameters. + #[derive( + Encode, Decode, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq, Debug, Default, + )] + pub enum VaultsManagerLevel { + /// Full administrative access via `GeneralAdmin` origin. + /// Can modify all parameters, raise or lower debt ceiling. + #[default] + Full, + /// Emergency access via `EmergencyAction` origin. + /// Can only lower the debt ceiling (defensive action). + Emergency, + } + + /// Purpose of a pUSD minting operation. + /// + /// Used by [`Pallet::do_mint`] to determine which invariants to enforce. + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + pub(crate) enum MintPurpose { + /// Minting new principal debt. + /// Subject to strict `MaximumIssuance` enforcement. + Principal, + /// Minting accrued interest. + /// Represents existing obligations; allowed even if ceiling is reached. + Interest, + } + + /// Unified balance type for both collateral (DOT) and stablecoin (pUSD). + pub type BalanceOf = + <::Collateral as Inspect<::AccountId>>::Balance; + + /// Type alias for the timestamp moment type from the time provider. + pub type MomentOf = <::TimeProvider as Time>::Moment; + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The collateral asset (native DOT). + /// Managed via `pallet_balances` using holds. + /// The Balance type is derived from this and must implement `FixedPointOperand`. + type Collateral: FungibleMutate + + FungibleBalanced + + MutateHold; + + /// The overarching runtime hold reason. + type RuntimeHoldReason: From; + + /// The stable asset used for pUSD debt. + /// Constrained to use the same Balance type as Collateral. + /// Also implements `Balanced` for creating credits during surplus transfers. + type StableAsset: FungibleMutate> + + FungibleBalanced; + + /// Time provider for fee accrual using UNIX timestamps. + type TimeProvider: Time; + + /// The Oracle providing timestamped asset prices. + /// + /// **Important**: The oracle must return prices in "normalized" format: + /// `smallest_pUSD_units per smallest_asset_unit` + /// + /// For example, with DOT (10 decimals) at $4.21 and pUSD (6 decimals): + /// - 1 DOT = 4.21 USD + /// - Price = 4.21 × 10^6 / 10^10 = 0.000421 + /// + /// This format allows the vault to perform decimal-agnostic calculations. + /// The oracle must also return a timestamp indicating when the price was last updated. + type Oracle: ProvidePrice>; + + /// The Auctions handler for liquidating collateral. + type AuctionsHandler: AuctionsHandler>; + + /// Handler for DOT received from surplus auctions. + /// + /// Use `ResolveTo` for simple single-account deposit, + /// or implement custom `OnUnbalanced` logic for fee splitting. + type FeeHandler: OnUnbalanced>; + + /// Handler for surplus pUSD transfers in `DirectTransfer` mode. + /// + /// Use `ResolveTo` for simple single-account deposit. + /// The credit is created from the Insurance Fund's pUSD. + type SurplusHandler: OnUnbalanced>; + + /// Origin allowed to update protocol parameters. + /// + /// Returns `VaultsManagerLevel` to distinguish privilege levels: + /// - `Full` (via GeneralAdmin): Can modify all parameters + /// - `Emergency` (via EmergencyAction): Can only lower debt ceiling + type ManagerOrigin: EnsureOrigin; + + /// A type representing the weights required by the dispatchables of this pallet. + type WeightInfo: crate::weights::WeightInfo; + + /// Helper type for benchmarking. + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper: BenchmarkHelper; + + /// Account that receives protocol revenue (interest and penalties). + #[pallet::constant] + type InsuranceFund: Get; + + /// Maximum number of vaults to process per `on_idle` call. + /// + /// This is a safety limit independent of weight to guard against benchmarking + /// inaccuracies. Even if weight budget allows more, iteration stops after this + /// many vaults. Set to `u32::MAX` to effectively disable this limit. + #[pallet::constant] + type MaxOnIdleItems: Get; + + /// The XCM Location of the collateral asset. + #[pallet::constant] + type CollateralLocation: Get; + } + + /// The in-code storage version. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + /// Helper trait for benchmarking setup. + /// + /// Provides methods to set up runtime state that cannot be done via Config bounds, + /// such as creating assets, manipulating time, and setting prices. + #[cfg(feature = "runtime-benchmarks")] + pub trait BenchmarkHelper { + /// Create the stablecoin asset if it doesn't exist. + fn create_stablecoin_asset(); + + /// Advance the timestamp by the given number of milliseconds. + fn advance_time(millis: u64); + + /// Set the oracle price for DOT/pUSD. + fn set_price(price: FixedU128); + } + + /// A Vault struct representing a CDP. + #[derive(Encode, Decode, MaxEncodedLen, TypeInfo, Clone, PartialEq, Debug)] + #[scale_info(skip_type_params(T))] + pub struct Vault { + /// Current status of the vault in its lifecycle. + pub status: VaultStatus, + /// Principal pUSD owed (excluding accrued interest). + pub principal: BalanceOf, + /// Accrued interest in pUSD. + pub accrued_interest: BalanceOf, + /// Timestamp (milliseconds since Unix epoch) when fees were last updated. + pub last_fee_update: MomentOf, + } + + impl Default for Vault { + fn default() -> Self { + Self::new() + } + } + + impl Vault { + /// Create a new healthy vault with zero debt and the current timestamp. + pub(crate) fn new() -> Self { + Self { + status: VaultStatus::Healthy, + principal: Zero::zero(), + accrued_interest: Zero::zero(), + last_fee_update: T::TimeProvider::now(), + } + } + + /// Get the total collateral held by the Balances pallet for this vault. + pub(crate) fn get_held_collateral(&self, who: &T::AccountId) -> BalanceOf { + T::Collateral::balance_on_hold(&HoldReason::VaultDeposit.into(), who) + } + + /// Returns total debt (principal + `accrued_interest`). + pub(crate) fn total_debt(&self) -> Result, Error> { + self.principal + .checked_add(&self.accrued_interest) + .ok_or(Error::::ArithmeticOverflow) + } + } + + /// Map of `AccountId` -> Vault. + /// Each account can only have one vault. + #[pallet::storage] + pub type Vaults = StorageMap<_, Blake2_128Concat, T::AccountId, Vault>; + + /// Minimum collateralization ratio + /// Below this ratio, a vault becomes eligible for liquidation. + /// Also used as the threshold for collateral withdrawals. + #[pallet::storage] + pub type MinimumCollateralizationRatio = StorageValue<_, FixedU128>; + + /// Initial collateralization ratio + /// Required when minting new debt. This is higher than the minimum ratio + /// to create a safety buffer preventing immediate liquidation after minting. + #[pallet::storage] + pub type InitialCollateralizationRatio = StorageValue<_, FixedU128>; + + /// Stability fee (annual interest rate). + #[pallet::storage] + pub type StabilityFee = StorageValue<_, Permill>; + + /// Previous stability fee and the timestamp when it was changed. + /// + /// Stored when [`StabilityFee`] is updated so that [`update_vault_fees`] can + /// split interest accrual across the fee change boundary, preventing the new + /// rate from being applied retroactively to periods before the change. + #[pallet::storage] + pub type PreviousStabilityFee = StorageValue<_, (Permill, MomentOf)>; + + /// Liquidation penalty + /// Applied to the debt during liquidation. The penalty is converted to DOT + /// and deducted from the collateral returned to the vault owner. + /// This incentivizes vault owners to maintain safe collateral levels. + #[pallet::storage] + pub type LiquidationPenalty = StorageValue<_, Permill>; + + /// Maximum total debt allowed in the system. + #[pallet::storage] + pub type MaximumIssuance = StorageValue<_, BalanceOf>; + + /// Accumulated bad debt in pUSD. + /// This represents unbacked pUSD (principal + interest) left after liquidation auctions. + #[pallet::storage] + pub type BadDebt = StorageValue<_, BalanceOf, ValueQuery>; + + /// Maximum pUSD that can be at risk in active auctions. + /// + /// This is a **hard limit** - liquidations are blocked when exceeded. + /// Governance can adjust this parameter to control auction exposure. + #[pallet::storage] + pub type MaxLiquidationAmount = StorageValue<_, BalanceOf>; + + /// Maximum pUSD debt that a single vault can have. + /// + /// Should be well below [`MaxLiquidationAmount`] to ensure liquidations proceed smoothly. + #[pallet::storage] + pub type MaxPositionAmount = StorageValue<_, BalanceOf>; + + /// Current pUSD at risk in active auctions. + /// + /// This accumulator tracks the unresolved auction exposure + /// (principal + interest + penalty) for all active auctions. + /// It increases when auctions start and decreases when purchases are + /// made or auctions complete (via callbacks from the Auctions pallet). + #[pallet::storage] + pub type CurrentLiquidationAmount = StorageValue<_, BalanceOf, ValueQuery>; + + /// Cursor for `on_idle` pagination. + /// + /// Stores the last processed vault owner's `AccountId` to continue iteration + /// across blocks. This prevents restarting from the beginning each block + /// and ensures all vaults are eventually processed. + #[pallet::storage] + pub type OnIdleCursor = StorageValue<_, T::AccountId, OptionQuery>; + + /// Minimum collateral deposit required to create a vault. + #[pallet::storage] + pub type MinimumDeposit = StorageValue<_, BalanceOf>; + + /// Minimum amount of stablecoin that can be minted in a single operation. + #[pallet::storage] + pub type MinimumMint = StorageValue<_, BalanceOf>; + + /// Duration (in milliseconds) before a vault is considered stale for `on_idle` fee accrual. + /// Suggested value: 14,400,000 ms (~4 hours). + #[pallet::storage] + pub type StaleVaultThreshold = StorageValue<_, MomentOf>; + + /// Maximum age (in milliseconds) of oracle price before operations are paused. + /// When the oracle price is older than this threshold, price-dependent operations + /// (mint, withdraw with debt, liquidate) will fail. + #[pallet::storage] + pub type OracleStalenessThreshold = StorageValue<_, MomentOf>; + + /// Genesis configuration for the vaults pallet. + #[pallet::genesis_config] + #[derive(DefaultNoBound)] + pub struct GenesisConfig { + /// Minimum collateralization ratio. + /// Below this ratio, a vault becomes eligible for liquidation. + pub minimum_collateralization_ratio: FixedU128, + /// Initial collateralization ratio. + /// Required when minting new debt. + pub initial_collateralization_ratio: FixedU128, + /// Stability fee (annual interest rate). + pub stability_fee: Permill, + /// Liquidation penalty. + pub liquidation_penalty: Permill, + /// Maximum total debt allowed in the system. + pub maximum_issuance: BalanceOf, + /// Maximum pUSD at risk in active auctions. + pub max_liquidation_amount: BalanceOf, + /// Maximum pUSD debt that a single vault can have. + pub max_position_amount: BalanceOf, + /// Minimum DOT required to create a vault. + pub minimum_deposit: BalanceOf, + /// Minimum pUSD amount that can be minted in a single operation. + pub minimum_mint: BalanceOf, + /// Milliseconds before a vault is considered stale for on_idle processing. + pub stale_vault_threshold: u64, + /// Maximum age (milliseconds) of oracle price before operations pause. + pub oracle_staleness_threshold: u64, + } + + #[pallet::genesis_build] + impl BuildGenesisConfig for GenesisConfig { + fn build(&self) { + MinimumCollateralizationRatio::::put(self.minimum_collateralization_ratio); + InitialCollateralizationRatio::::put(self.initial_collateralization_ratio); + StabilityFee::::put(self.stability_fee); + LiquidationPenalty::::put(self.liquidation_penalty); + MaximumIssuance::::put(self.maximum_issuance); + MaxLiquidationAmount::::put(self.max_liquidation_amount); + MaxPositionAmount::::put(self.max_position_amount); + MinimumDeposit::::put(self.minimum_deposit); + MinimumMint::::put(self.minimum_mint); + StaleVaultThreshold::::put( + self.stale_vault_threshold.saturated_into::>(), + ); + OracleStalenessThreshold::::put( + self.oracle_staleness_threshold.saturated_into::>(), + ); + Pallet::::ensure_insurance_fund_exists(); + } + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A new vault was created with initial collateral deposit. + VaultCreated { owner: T::AccountId }, + /// Collateral (DOT) was deposited into a vault. + CollateralDeposited { owner: T::AccountId, amount: BalanceOf }, + /// Collateral (DOT) was withdrawn from a vault. + CollateralWithdrawn { owner: T::AccountId, amount: BalanceOf }, + /// Stablecoin (pUSD) was minted against vault collateral. + Minted { owner: T::AccountId, amount: BalanceOf }, + /// Debt (pUSD) was repaid and burned. + Repaid { + /// The vault owner who repaid debt. + owner: T::AccountId, + /// Principal portion burned. + principal: BalanceOf, + /// Interest portion burned. + interest: BalanceOf, + }, + /// Excess pUSD returned when repayment exceeded debt. + ReturnedExcess { owner: T::AccountId, amount: BalanceOf }, + /// A vault entered liquidation due to undercollateralization. + InLiquidation { + /// The vault owner whose position is being liquidated. + owner: T::AccountId, + /// Outstanding debt at time of liquidation. + debt: BalanceOf, + /// Collateral seized for auction (after interest and penalty). + collateral_seized: BalanceOf, + }, + /// A vault was closed and all collateral returned to owner. + VaultClosed { owner: T::AccountId }, + /// Interest accrued on vault debt and minted to Insurance Fund. + InterestAccrued { + /// The vault owner whose debt accrued interest. + owner: T::AccountId, + /// Interest amount in pUSD minted to Insurance Fund. + amount: BalanceOf, + }, + /// Liquidation penalty applied to vault debt during liquidation. + /// The penalty is collected later during auction purchases. + LiquidationPenaltyAdded { owner: T::AccountId, amount: BalanceOf }, + /// Minimum collateralization ratio was updated by governance. + MinimumCollateralizationRatioUpdated { old_value: FixedU128, new_value: FixedU128 }, + /// Initial collateralization ratio was updated by governance. + InitialCollateralizationRatioUpdated { old_value: FixedU128, new_value: FixedU128 }, + /// Stability fee was updated by governance. + StabilityFeeUpdated { old_value: Permill, new_value: Permill }, + /// Liquidation penalty was updated by governance. + LiquidationPenaltyUpdated { old_value: Permill, new_value: Permill }, + /// Maximum system debt ceiling was updated by governance. + MaximumIssuanceUpdated { old_value: BalanceOf, new_value: BalanceOf }, + /// Maximum liquidation amount was updated by governance. + MaxLiquidationAmountUpdated { old_value: BalanceOf, new_value: BalanceOf }, + /// Maximum single vault debt was updated by governance. + MaxPositionAmountUpdated { old_value: BalanceOf, new_value: BalanceOf }, + /// Minimum deposit amount was updated by governance. + MinimumDepositUpdated { old_value: BalanceOf, new_value: BalanceOf }, + /// Minimum mint amount was updated by governance. + MinimumMintUpdated { old_value: BalanceOf, new_value: BalanceOf }, + /// Stale vault threshold was updated by governance. + StaleVaultThresholdUpdated { old_value: MomentOf, new_value: MomentOf }, + /// Oracle staleness threshold was updated by governance. + OracleStalenessThresholdUpdated { old_value: MomentOf, new_value: MomentOf }, + /// Bad debt accrued when auctions leave unbacked pUSD (principal + interest). + BadDebtAccrued { + /// The vault owner whose liquidation resulted in bad debt. + owner: T::AccountId, + /// Uncollectable pUSD (principal + interest) added to system bad debt. + amount: BalanceOf, + }, + /// Bad debt was healed by burning pUSD from `InsuranceFund`. + BadDebtRepaid { amount: BalanceOf }, + /// A Dutch auction was started for liquidated collateral. + AuctionStarted { + /// The liquidated vault owner. + owner: T::AccountId, + /// Unique identifier for this auction. + auction_id: u32, + /// Collateral available for auction (lot). + collateral: BalanceOf, + /// Debt to raise from auction (tab). + tab: BalanceOf, + }, + /// pUSD collected from auction purchase; `CurrentLiquidationAmount` reduced. + AuctionDebtCollected { amount: BalanceOf }, + /// Auction completed with unresolved debt remainder. + AuctionShortfall { shortfall: BalanceOf }, + /// Keeper incentive payment could not be paid from the Insurance Fund. + KeeperIncentivePaymentFailed { keeper: T::AccountId, amount: BalanceOf }, + } + + #[pallet::error] + pub enum Error { + /// No vault exists for the specified account. + /// + /// Create a vault first using [`Pallet::create_vault`] before attempting other operations. + VaultNotFound, + /// Insufficient collateral for the requested operation. + /// + /// Deposit more collateral or reduce the withdrawal amount. + InsufficientCollateral, + /// Minting would exceed the system-wide maximum debt ceiling. + /// + /// Wait for system debt to decrease or governance to raise [`MaximumIssuance`]. + ExceedsMaxDebt, + /// Minting would exceed maximum single vault debt. + /// + /// Wait for vault debt to decrease or governance to raise [`MaxPositionAmount`]. + ExceedsMaxPositionAmount, + /// Operation would breach the required collateralization ratio. + /// + /// Deposit more collateral, reduce mint amount, or reduce withdrawal amount to maintain + /// the required ratio (either [`InitialCollateralizationRatio`] for minting/withdrawals + /// or [`MinimumCollateralizationRatio`] for liquidation safety). + UnsafeCollateralizationRatio, + /// Account already has an active vault. + /// + /// Each account can only have one vault. Use the existing vault or close it first. + VaultAlreadyExists, + /// Arithmetic operation overflowed. + /// + /// This indicates an internal calculation exceeded safe bounds. Try different amounts. + ArithmeticOverflow, + /// Arithmetic operation underflowed. + /// + /// This indicates inconsistent accounting, such as an auction callback trying to remove + /// more liquidation exposure than is currently tracked. + ArithmeticUnderflow, + /// Vault is sufficiently collateralized and cannot be liquidated. + /// + /// The vault's collateralization ratio is above [`MinimumCollateralizationRatio`]. + /// Liquidation is only possible when the ratio falls below this threshold. + VaultIsSafe, + /// Oracle price not available for collateral asset. + /// + /// The oracle has not reported a price for the collateral. Wait for oracle update. + PriceNotAvailable, + /// Oracle price is stale. + /// + /// The oracle price is older than [`Config::OracleStalenessThreshold`]. + /// Wait for the oracle to provide a fresh price update. + OracleStale, + /// Cannot close vault with outstanding debt. + /// + /// Repay all principal debt using [`Pallet::repay`] before closing the vault. + VaultHasDebt, + /// Deposit or remaining collateral below minimum threshold. + /// + /// Ensure deposit amount is at least [`Config::MinimumDeposit`], or when withdrawing, + /// leave at least that amount (or withdraw everything to close the vault). + BelowMinimumDeposit, + /// Mint amount below minimum threshold. + /// + /// Ensure mint amount is at least [`Config::MinimumMint`]. + BelowMinimumMint, + /// Vault is in liquidation; operations blocked until auction completes. + /// + /// Wait for the auction to complete. The vault will be removed once the auction ends. + VaultInLiquidation, + /// Origin lacks required privilege level. + /// + /// This operation requires [`VaultsManagerLevel::Full`] privilege. Emergency origins + /// cannot perform this action. + InsufficientPrivilege, + /// Emergency origin can only lower the maximum debt, not raise it. + /// + /// Use a Full privilege origin to raise the debt ceiling, or specify a lower value. + CanOnlyLowerMaxDebt, + /// Liquidation would exceed maximum liquidation amount. + /// + /// The system has reached its limit for debt at risk in active auctions. Wait for + /// existing auctions to complete or governance to raise [`MaxLiquidationAmount`]. + ExceedsMaxLiquidationAmount, + /// Initial collateralization ratio must be >= minimum ratio. + /// + /// The [`InitialCollateralizationRatio`] cannot be set below + /// [`MinimumCollateralizationRatio`] as it would prevent any borrowing. + InitialRatioMustExceedMinimum, + /// Pallet parameters have not been initialized. + /// + /// The pallet requires all parameters to be set via migration or governance + /// before any operations can proceed. + NotConfigured, + /// Zero is not a valid value for this parameter. + ZeroValueNotAllowed, + /// Minimum collateralization ratio must be strictly above 100%. + /// + /// A ratio of 100% or below means the collateral value equals or is less than the debt, + /// providing no liquidation buffer. + MinimumRatioTooLow, + /// MaxLiquidationAmount must be >= MaxPositionAmount. + /// + /// A single vault's maximum debt must fit within the liquidation capacity. + MaxLiquidationBelowMaxPosition, + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn integrity_test() { + assert!(!T::MaxOnIdleItems::get().is_zero(), "MaxOnIdleItems must be non-zero"); + } + + #[cfg(feature = "try-runtime")] + fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { + Self::do_try_state() + } + + /// Idle block housekeeping: update fees for stale vaults. + /// + /// Vaults inactive for >= `StaleVaultThreshold` get their fees updated. + /// Uses cursor-based pagination to continue across blocks, ensuring all + /// vaults are eventually processed without unbounded iteration. + fn on_idle(_now: BlockNumberFor, limit: Weight) -> Weight { + let mut meter = WeightMeter::with_limit(limit); + + // Early exit if not enough weight for base overhead + let base_weight = Self::on_idle_base_weight(); + if meter.try_consume(base_weight).is_err() { + return meter.consumed(); + } + + let current_timestamp = T::TimeProvider::now(); + let stale_threshold = match StaleVaultThreshold::::get() { + Some(t) => t, + None => return meter.consumed(), + }; + let per_vault_weight = Self::on_idle_per_vault_weight(); + let max_items = T::MaxOnIdleItems::get(); + + // Build iterator from cursor position + let cursor = OnIdleCursor::::get(); + let mut iter = cursor.as_ref().map_or_else(Vaults::::iter, |last_key| { + Vaults::::iter_from(Vaults::::hashed_key_for(last_key)) + }); + + // Tracks the last vault that consumed an iteration slot. This is the cursor invariant: + // once a vault has consumed weight / item budget for this pass, pagination advances + // past it even if fee processing later decides to skip or fails. + let mut last_processed: Option = None; + let mut items_processed: u32 = 0; + let mut stopped_early = false; + + loop { + let Some((owner, vault)) = iter.next() else { break }; + + // Safety limit: stop if we've processed max items, regardless of weight + if items_processed >= max_items { + stopped_early = true; + break; + } + + // Check weight budget for processing this vault + if meter.try_consume(per_vault_weight).is_err() { + stopped_early = true; + break; + } + + items_processed = items_processed.saturating_add(1); + last_processed = Some(owner.clone()); + Self::process_on_idle_vault(owner, vault, current_timestamp, stale_threshold); + } + + // Update cursor based on how we exited + if stopped_early { + if let Some(last) = last_processed { + OnIdleCursor::::put(last); + } + } else { + // Natural end of iteration - clear cursor to restart next time + OnIdleCursor::::kill(); + } + + meter.consumed() + } + } + + #[pallet::call] + impl Pallet { + /// Create a new vault with initial collateral deposit. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed` by the account that will own the vault. + /// + /// ## Details + /// + /// Creates a new vault for the caller with the specified initial collateral deposit. + /// The collateral is held using the [`HoldReason::VaultDeposit`] reason. Each account + /// can only have one vault at a time. + /// + /// ## Errors + /// + /// - [`Error::BelowMinimumDeposit`]: If `initial_deposit` is less than + /// [`Config::MinimumDeposit`]. + /// - [`Error::VaultAlreadyExists`]: If the caller already has an active vault. + /// + /// ## Events + /// + /// - [`Event::VaultCreated`]: Emitted when the vault is successfully created. + /// - [`Event::CollateralDeposited`]: Emitted for the initial collateral deposit. + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::create_vault())] + pub fn create_vault(origin: OriginFor, initial_deposit: BalanceOf) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!( + initial_deposit >= MinimumDeposit::::get().ok_or(Error::::NotConfigured)?, + Error::::BelowMinimumDeposit + ); + + Vaults::::try_mutate_exists(&who, |maybe_vault| -> DispatchResult { + ensure!(maybe_vault.is_none(), Error::::VaultAlreadyExists); + T::Collateral::hold(&HoldReason::VaultDeposit.into(), &who, initial_deposit)?; + *maybe_vault = Some(Vault::new()); + + Self::deposit_event(Event::VaultCreated { owner: who.clone() }); + Self::deposit_event(Event::CollateralDeposited { + owner: who.clone(), + amount: initial_deposit, + }); + + Ok(()) + }) + } + + /// Deposit additional collateral into an existing vault. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed` by the vault owner. + /// + /// ## Details + /// + /// Adds collateral to an existing vault. The amount is held using the + /// [`HoldReason::VaultDeposit`] reason. Any accrued stability fees are updated + /// before the deposit is processed. + /// + /// ## Errors + /// + /// - [`Error::VaultNotFound`]: If the caller does not have a vault. + /// - [`Error::VaultInLiquidation`]: If the vault is currently being liquidated. + /// + /// ## Events + /// + /// - [`Event::CollateralDeposited`]: Emitted when collateral is successfully deposited. + /// - [`Event::InterestAccrued`]: Emitted if stability fees were accrued. + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::deposit_collateral())] + pub fn deposit_collateral(origin: OriginFor, amount: BalanceOf) -> DispatchResult { + let who = ensure_signed(origin)?; + + Vaults::::try_mutate(&who, |maybe_vault| -> DispatchResult { + let vault = maybe_vault.as_mut().ok_or(Error::::VaultNotFound)?; + + ensure!(vault.status == VaultStatus::Healthy, Error::::VaultInLiquidation); + + Self::update_vault_fees(vault, &who, None)?; + + T::Collateral::hold(&HoldReason::VaultDeposit.into(), &who, amount)?; + + Self::deposit_event(Event::CollateralDeposited { owner: who.clone(), amount }); + Ok(()) + }) + } + + /// Withdraw collateral from a vault. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed` by the vault owner. + /// + /// ## Details + /// + /// Releases collateral from the vault back to the owner's free balance. Any accrued + /// stability fees are updated first. If the vault has outstanding debt, the withdrawal + /// must maintain the [`InitialCollateralizationRatio`] to preserve a safety buffer. + /// If remaining collateral is non-zero, it must meet [`Config::MinimumDeposit`]. + /// Withdrawing all collateral when debt is zero will auto-close the vault. + /// + /// ## Errors + /// + /// - [`Error::VaultNotFound`]: If the caller does not have a vault. + /// - [`Error::VaultInLiquidation`]: If the vault is currently being liquidated. + /// - [`Error::InsufficientCollateral`]: If `amount` exceeds available collateral. + /// - [`Error::BelowMinimumDeposit`]: If remaining collateral is below the minimum. + /// - [`Error::VaultHasDebt`]: If attempting to withdraw all collateral while debt exists. + /// - [`Error::UnsafeCollateralizationRatio`]: If withdrawal would breach initial ratio. + /// - [`Error::PriceNotAvailable`]: If the oracle price is unavailable. + /// - [`Error::OracleStale`]: If the oracle price is too old. + /// + /// ## Events + /// + /// - [`Event::CollateralWithdrawn`]: Emitted when collateral is released. + /// - [`Event::VaultClosed`]: Emitted if the vault is auto-closed (zero collateral, zero + /// debt). + /// - [`Event::InterestAccrued`]: Emitted if stability fees were accrued. + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::withdraw_collateral())] + pub fn withdraw_collateral(origin: OriginFor, amount: BalanceOf) -> DispatchResult { + let who = ensure_signed(origin)?; + + Vaults::::try_mutate_exists(&who, |maybe_vault| -> DispatchResult { + let vault = maybe_vault.as_mut().ok_or(Error::::VaultNotFound)?; + + ensure!(vault.status == VaultStatus::Healthy, Error::::VaultInLiquidation); + + Self::update_vault_fees(vault, &who, None)?; + + let available = vault.get_held_collateral(&who); + ensure!(available >= amount, Error::::InsufficientCollateral); + + let remaining_collateral = available.saturating_sub(amount); + + if remaining_collateral.is_zero() { + // Withdrawing all collateral (when debt == 0) auto-closes the vault. + Self::do_close_vault(vault, &who)?; + *maybe_vault = None; + } else { + // Prevent dust vaults: remaining collateral must meet MinimumDeposit. + ensure!( + remaining_collateral >= + MinimumDeposit::::get().ok_or(Error::::NotConfigured)?, + Error::::BelowMinimumDeposit + ); + + // Partial withdrawal: check CR if there's debt + let total_obligation = vault.total_debt()?; + + if !total_obligation.is_zero() { + // CR = remaining_collateral × Price / (Principal + AccruedInterest) + let ratio = Self::calculate_collateralization_ratio( + remaining_collateral, + total_obligation, + )?; + let initial_ratio = InitialCollateralizationRatio::::get() + .ok_or(Error::::NotConfigured)?; + ensure!(ratio >= initial_ratio, Error::::UnsafeCollateralizationRatio); + } + + T::Collateral::release( + &HoldReason::VaultDeposit.into(), + &who, + amount, + Precision::Exact, + )?; + + Self::deposit_event(Event::CollateralWithdrawn { owner: who.clone(), amount }); + } + + Ok(()) + }) + } + + /// Mint stablecoin (pUSD) against collateral. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed` by the vault owner. + /// + /// ## Details + /// + /// Mints pUSD stablecoins by increasing the vault's principal debt. Any accrued + /// stability fees are updated first. The vault must maintain the + /// [`InitialCollateralizationRatio`] to create a safety buffer + /// preventing immediate liquidation after minting. The total system debt cannot + /// exceed [`MaximumIssuance`], and the vault's debt cannot exceed [`MaxPositionAmount`]. + /// + /// ## Errors + /// + /// - [`Error::VaultNotFound`]: If the caller does not have a vault. + /// - [`Error::VaultInLiquidation`]: If the vault is currently being liquidated. + /// - [`Error::BelowMinimumMint`]: If `amount` is below [`Config::MinimumMint`]. + /// - [`Error::ExceedsMaxDebt`]: If minting would exceed the system debt ceiling. + /// - [`Error::ExceedsMaxPositionAmount`]: If minting would exceed max single vault debt. + /// - [`Error::UnsafeCollateralizationRatio`]: If minting would breach initial ratio. + /// - [`Error::PriceNotAvailable`]: If the oracle price is unavailable. + /// - [`Error::OracleStale`]: If the oracle price is too old. + /// + /// ## Events + /// + /// - [`Event::Minted`]: Emitted when pUSD is successfully minted. + /// - [`Event::InterestAccrued`]: Emitted if stability fees were accrued. + #[pallet::call_index(3)] + #[pallet::weight(T::WeightInfo::mint())] + pub fn mint(origin: OriginFor, amount: BalanceOf) -> DispatchResult { + let who = ensure_signed(origin)?; + + Vaults::::try_mutate(&who, |maybe_vault| -> DispatchResult { + let vault = maybe_vault.as_mut().ok_or(Error::::VaultNotFound)?; + + ensure!(vault.status == VaultStatus::Healthy, Error::::VaultInLiquidation); + + ensure!( + amount >= MinimumMint::::get().ok_or(Error::::NotConfigured)?, + Error::::BelowMinimumMint + ); + + Self::update_vault_fees(vault, &who, None)?; + + let new_principal = + vault.principal.checked_add(&amount).ok_or(Error::::ArithmeticOverflow)?; + + // Check vault's resulting debt does not exceed MaxPositionAmount + ensure!( + new_principal <= + MaxPositionAmount::::get().ok_or(Error::::NotConfigured)?, + Error::::ExceedsMaxPositionAmount + ); + + vault.principal = new_principal; + + // Check collateralization ratio (CR). Use InitialCollateralizationRatio for minting + // to create safety buffer. + let ratio = Self::get_collateralization_ratio(vault, &who)?; + let initial_ratio = + InitialCollateralizationRatio::::get().ok_or(Error::::NotConfigured)?; + ensure!(ratio >= initial_ratio, Error::::UnsafeCollateralizationRatio); + + Self::do_mint(&who, amount, MintPurpose::Principal)?; + + Self::deposit_event(Event::Minted { owner: who.clone(), amount }); + Ok(()) + }) + } + + /// Repay debt by burning pUSD. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed` by the vault owner. + /// + /// ## Details + /// + /// Reduces vault debt by burning pUSD from the caller. Any accrued stability fees are + /// updated before repayment is processed. Payment is applied in order: accrued interest + /// first, then principal (both burned). The Insurance Fund already received the interest + /// when it was minted during fee accrual. If `amount` exceeds total obligation, the + /// excess is reported but not consumed. + /// + /// ## Errors + /// + /// - [`Error::VaultNotFound`]: If the caller does not have a vault. + /// - [`Error::VaultInLiquidation`]: If the vault is currently being liquidated. + /// + /// ## Events + /// + /// - [`Event::InterestAccrued`]: Emitted if stability fees were accrued before repayment. + /// - [`Event::Repaid`]: Emitted when debt (interest and/or principal) is burned. + /// - [`Event::ReturnedExcess`]: Emitted if `amount` exceeded total obligation. + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::repay())] + pub fn repay(origin: OriginFor, amount: BalanceOf) -> DispatchResult { + let who = ensure_signed(origin)?; + + Vaults::::try_mutate(&who, |maybe_vault| -> DispatchResult { + let vault = maybe_vault.as_mut().ok_or(Error::::VaultNotFound)?; + + ensure!(vault.status == VaultStatus::Healthy, Error::::VaultInLiquidation); + + Self::update_vault_fees(vault, &who, None)?; + + // Payment order: interest first, then principal + // 1. Calculate how much interest to pay (capped by available amount) + let interest_to_pay = vault.accrued_interest.min(amount); + let remaining_after_interest = amount.saturating_sub(interest_to_pay); + + // 2. Calculate how much principal to pay (capped by remaining amount) + let principal_to_pay = vault.principal.min(remaining_after_interest); + + // 3. Calculate true excess (unused after interest + principal) + let true_excess = remaining_after_interest.saturating_sub(principal_to_pay); + + // Burn interest pUSD from payer. + // Note: The Insurance Fund already received this pUSD when it was minted + // during fee accrual (mint-on-accrual model). Burning here reduces supply. + if !interest_to_pay.is_zero() { + T::StableAsset::burn_from( + &who, + interest_to_pay, + Preservation::Expendable, + Precision::Exact, + Fortitude::Polite, + )?; + vault.accrued_interest = vault.accrued_interest.saturating_sub(interest_to_pay); + } + + // Burn principal pUSD from payer + if !principal_to_pay.is_zero() { + T::StableAsset::burn_from( + &who, + principal_to_pay, + Preservation::Expendable, + Precision::Exact, + Fortitude::Polite, + )?; + vault.principal = vault.principal.saturating_sub(principal_to_pay); + } + + if !interest_to_pay.is_zero() || !principal_to_pay.is_zero() { + Self::deposit_event(Event::Repaid { + owner: who.clone(), + principal: principal_to_pay, + interest: interest_to_pay, + }); + } + + if !true_excess.is_zero() { + Self::deposit_event(Event::ReturnedExcess { + owner: who.clone(), + amount: true_excess, + }); + } + + Ok(()) + }) + } + + /// Liquidate an undercollateralized vault. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed`. Anyone can call this function to liquidate an unsafe vault + /// (acting as a "keeper"). + /// + /// ## Details + /// + /// Initiates an auction for the vault's collateral when the vault's + /// collateralization ratio falls below [`MinimumCollateralizationRatio`]. + /// The auction will attempt to raise enough pUSD to cover the debt plus the + /// [`LiquidationPenalty`]. The collateral hold reason changes from + /// [`HoldReason::VaultDeposit`] to [`HoldReason::Seized`]. + /// + /// **Process:** + /// 1. Verify vault is undercollateralized (ratio < [`MinimumCollateralizationRatio`]) + /// 2. Calculate liquidation penalty based on principal + /// 3. Update [`CurrentLiquidationAmount`] accumulator + /// 4. Seize collateral and start auction via [`Config::AuctionsHandler`] + /// + /// ## Errors + /// + /// - [`Error::VaultNotFound`]: If the target vault does not exist. + /// - [`Error::VaultInLiquidation`]: If the vault is already being liquidated. + /// - [`Error::VaultIsSafe`]: If the vault's ratio is above the minimum threshold. + /// - [`Error::ExceedsMaxLiquidationAmount`]: If liquidation would exceed the hard limit. + /// - [`Error::PriceNotAvailable`]: If the oracle price is unavailable. + /// - [`Error::OracleStale`]: If the oracle price is too old. + /// + /// ## Events + /// + /// - [`Event::LiquidationPenaltyAdded`]: Emitted with the calculated penalty amount. + /// - [`Event::InLiquidation`]: Emitted when the vault enters liquidation state. + /// - [`Event::AuctionStarted`]: Emitted with auction details. + /// - [`Event::InterestAccrued`]: Emitted if stability fees were accrued. + #[pallet::call_index(5)] + #[pallet::weight(T::WeightInfo::liquidate_vault())] + #[transactional] + pub fn liquidate_vault( + origin: OriginFor, + vault_owner: T::AccountId, + ) -> DispatchResultWithPostInfo { + let keeper = ensure_signed(origin)?; + + Vaults::::try_mutate(&vault_owner, |maybe_vault| -> DispatchResult { + let vault = maybe_vault.as_mut().ok_or(Error::::VaultNotFound)?; + + ensure!(vault.status == VaultStatus::Healthy, Error::::VaultInLiquidation); + + Self::update_vault_fees(vault, &vault_owner, None)?; + + let principal = vault.principal; + let interest = vault.accrued_interest; + let collateral_seized = vault.get_held_collateral(&vault_owner); + let total_obligation = + principal.checked_add(&interest).ok_or(Error::::ArithmeticOverflow)?; + + // Check if vault is undercollateralized + // CR = HeldCollateral × Price / (Principal + AccruedInterest) + // A vault with no debt is always safe + ensure!(!total_obligation.is_zero(), Error::::VaultIsSafe); + let ratio = + Self::calculate_collateralization_ratio(collateral_seized, total_obligation)?; + let min_ratio = + MinimumCollateralizationRatio::::get().ok_or(Error::::NotConfigured)?; + ensure!(ratio < min_ratio, Error::::VaultIsSafe); + + // Calculate liquidation penalty in pUSD (applied to principal) + let liquidation_penalty = + LiquidationPenalty::::get().ok_or(Error::::NotConfigured)?; + let penalty = liquidation_penalty.mul_floor(principal); + + // Total debt for the auction includes principal + interest + penalty + let total_debt = + total_obligation.checked_add(&penalty).ok_or(Error::::ArithmeticOverflow)?; + + // Check if liquidation would exceed hard limit. + // Track full auction exposure (principal + interest + penalty). + let current_liquidation = CurrentLiquidationAmount::::get(); + let max_liquidation = + MaxLiquidationAmount::::get().ok_or(Error::::NotConfigured)?; + let new_liquidation_amount = current_liquidation + .checked_add(&total_debt) + .ok_or(Error::::ArithmeticOverflow)?; + ensure!( + new_liquidation_amount <= max_liquidation, + Error::::ExceedsMaxLiquidationAmount + ); + + CurrentLiquidationAmount::::put(new_liquidation_amount); + + // Emit penalty collected event + if !penalty.is_zero() { + Self::deposit_event(Event::LiquidationPenaltyAdded { + owner: vault_owner.clone(), + amount: penalty, + }); + } + + // Change hold reason from VaultDeposit to Seized + // The collateral stays in the user's account but is now controlled by the auction + // pallet + T::Collateral::release( + &HoldReason::VaultDeposit.into(), + &vault_owner, + collateral_seized, + Precision::Exact, + )?; + + // Immediately re-hold with Seized reason + T::Collateral::hold(&HoldReason::Seized.into(), &vault_owner, collateral_seized)?; + + // Start the auction - collateral (native DOT) is held with Seized reason + let debt = DebtComponents { principal, interest, penalty }; + let auction_id = T::AuctionsHandler::start_auction( + vault_owner.clone(), + collateral_seized, + debt, + keeper.clone(), + )?; + + // Mark vault as in liquidation (will be removed when auction completes) + vault.status = VaultStatus::InLiquidation; + + log::info!( + target: LOG_TARGET, + "Vault liquidated: owner={:?}, principal={:?}, collateral={:?}, auction_id={}, ratio={:?}", + vault_owner, + principal, + collateral_seized, + auction_id, + ratio + ); + + Self::deposit_event(Event::InLiquidation { + owner: vault_owner.clone(), + debt: total_debt, + collateral_seized, + }); + + Self::deposit_event(Event::AuctionStarted { + owner: vault_owner.clone(), + auction_id, + collateral: collateral_seized, + tab: total_debt, + }); + + Ok(()) + })?; + + Ok(Pays::No.into()) + } + + /// Close a vault with no debt and withdraw all collateral. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed` by the vault owner. + /// + /// ## Details + /// + /// Closes the vault and releases all collateral to the owner. Can only be called + /// when `principal == 0`. Any accrued interest is transferred to + /// [`Config::InsuranceFund`] before closing. The vault is removed from storage. + /// + /// ## Errors + /// + /// - [`Error::VaultNotFound`]: If the caller does not have a vault. + /// - [`Error::VaultInLiquidation`]: If the vault is currently being liquidated. + /// - [`Error::VaultHasDebt`]: If the vault has outstanding principal debt. + /// + /// ## Events + /// + /// - [`Event::InterestAccrued`]: Emitted if accrued interest was paid. + /// - [`Event::CollateralWithdrawn`]: Emitted when collateral is released. + /// - [`Event::VaultClosed`]: Emitted when the vault is removed. + #[pallet::call_index(6)] + #[pallet::weight(T::WeightInfo::close_vault())] + pub fn close_vault(origin: OriginFor) -> DispatchResult { + let who = ensure_signed(origin)?; + + Vaults::::try_mutate_exists(&who, |maybe_vault| -> DispatchResult { + let vault = maybe_vault.as_mut().ok_or(Error::::VaultNotFound)?; + + ensure!(vault.status == VaultStatus::Healthy, Error::::VaultInLiquidation); + + Self::update_vault_fees(vault, &who, None)?; + Self::do_close_vault(vault, &who)?; + *maybe_vault = None; + + Ok(()) + }) + } + + /// Set the minimum collateralization ratio. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`MinimumCollateralizationRatio`] below which vaults become eligible + /// for liquidation. The ratio is expressed as [`FixedU128`] (e.g., 1.8 for 180%). + /// The new ratio cannot exceed [`InitialCollateralizationRatio`] to maintain the + /// safety buffer that prevents immediate liquidation after minting. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// - [`Error::MinimumRatioTooLow`]: If ratio is not strictly above 100%. + /// - [`Error::InitialRatioMustExceedMinimum`]: If ratio exceeds initial ratio. + /// + /// ## Events + /// + /// - [`Event::MinimumCollateralizationRatioUpdated`]: Emitted with old and new values. + #[pallet::call_index(7)] + #[pallet::weight(T::WeightInfo::set_minimum_collateralization_ratio())] + pub fn set_minimum_collateralization_ratio( + origin: OriginFor, + ratio: FixedU128, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + // Must be strictly above 100% to provide a liquidation buffer + ensure!(ratio > FixedU128::one(), Error::::MinimumRatioTooLow); + // Minimum ratio cannot exceed initial ratio (would allow immediate-liquidation mints) + if let Some(initial_ratio) = InitialCollateralizationRatio::::get() { + ensure!(ratio <= initial_ratio, Error::::InitialRatioMustExceedMinimum); + } + let old_value = MinimumCollateralizationRatio::::get().unwrap_or_default(); + MinimumCollateralizationRatio::::put(ratio); + Self::deposit_event(Event::MinimumCollateralizationRatioUpdated { + old_value, + new_value: ratio, + }); + Ok(()) + } + + /// Set the stability fee (annual interest rate). + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`StabilityFee`] used to calculate interest accrual on vault debt. + /// The fee is expressed as [`Permill`] (e.g., `Permill::from_percent(5)` for 5% APR). + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// + /// ## Events + /// + /// - [`Event::StabilityFeeUpdated`]: Emitted with old and new values. + #[pallet::call_index(8)] + #[pallet::weight(T::WeightInfo::set_stability_fee())] + pub fn set_stability_fee(origin: OriginFor, fee: Permill) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + let old_value = StabilityFee::::get().unwrap_or_default(); + PreviousStabilityFee::::put((old_value, T::TimeProvider::now())); + StabilityFee::::put(fee); + Self::deposit_event(Event::StabilityFeeUpdated { old_value, new_value: fee }); + Ok(()) + } + + /// Set the initial collateralization ratio. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`InitialCollateralizationRatio`] required when minting new debt or + /// withdrawing collateral with existing debt. This ratio must be greater than or + /// equal to [`MinimumCollateralizationRatio`] to create a safety buffer preventing + /// immediate liquidation. Expressed as [`FixedU128`] (e.g., 2.0 for 200%). + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// - [`Error::InitialRatioMustExceedMinimum`]: If ratio is below minimum. + /// + /// ## Events + /// + /// - [`Event::InitialCollateralizationRatioUpdated`]: Emitted with old and new values. + #[pallet::call_index(9)] + #[pallet::weight(T::WeightInfo::set_initial_collateralization_ratio())] + pub fn set_initial_collateralization_ratio( + origin: OriginFor, + ratio: FixedU128, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + // Initial ratio must be >= minimum ratio to allow borrowing + if let Some(min_ratio) = MinimumCollateralizationRatio::::get() { + ensure!(ratio >= min_ratio, Error::::InitialRatioMustExceedMinimum); + } + let old_value = InitialCollateralizationRatio::::get().unwrap_or_default(); + InitialCollateralizationRatio::::put(ratio); + Self::deposit_event(Event::InitialCollateralizationRatioUpdated { + old_value, + new_value: ratio, + }); + Ok(()) + } + + /// Set the liquidation penalty. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`LiquidationPenalty`] applied to debt during liquidation. The penalty + /// is added to the auction tab and incentivizes vault owners to maintain safe + /// collateral levels. Expressed as [`Permill`] (e.g., `Permill::from_percent(13)` + /// for 13%). + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// + /// ## Events + /// + /// - [`Event::LiquidationPenaltyUpdated`]: Emitted with old and new values. + #[pallet::call_index(10)] + #[pallet::weight(T::WeightInfo::set_liquidation_penalty())] + pub fn set_liquidation_penalty(origin: OriginFor, penalty: Permill) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + let old_value = LiquidationPenalty::::get().unwrap_or_default(); + LiquidationPenalty::::put(penalty); + Self::deposit_event(Event::LiquidationPenaltyUpdated { old_value, new_value: penalty }); + Ok(()) + } + + /// Repay accumulated bad debt by burning pUSD from the `InsuranceFund`. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed`. Anyone can trigger bad debt repayment. + /// + /// ## Details + /// + /// Burns pUSD from [`Config::InsuranceFund`] to reduce [`BadDebt`] accumulated + /// from auction shortfalls. If `amount` exceeds current bad debt, only the bad + /// debt amount is burned. + /// + /// ## Events + /// + /// - [`Event::BadDebtRepaid`]: Emitted with the amount of bad debt healed. + #[pallet::call_index(11)] + #[pallet::weight(T::WeightInfo::heal())] + pub fn heal(origin: OriginFor, amount: BalanceOf) -> DispatchResultWithPostInfo { + ensure_signed(origin)?; + + let current_bad_debt = BadDebt::::get(); + let repay_amount = amount.min(current_bad_debt); + + if repay_amount.is_zero() { + return Ok(Pays::Yes.into()); + } + + // Burn pUSD from the InsuranceFund to cover the bad debt + let burned = T::StableAsset::burn_from( + &T::InsuranceFund::get(), + repay_amount, + Preservation::Expendable, + Precision::BestEffort, + Fortitude::Polite, + )?; + if burned.is_zero() { + return Ok(Pays::Yes.into()); + } + + // Reduce bad debt + BadDebt::::mutate(|debt| { + *debt = debt.defensive_saturating_sub(burned); + }); + + Self::deposit_event(Event::BadDebtRepaid { amount: burned }); + + Ok(Pays::No.into()) + } + + /// Set the maximum pUSD at risk in active auctions. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`MaxLiquidationAmount`] which is a **hard limit** on total pUSD debt + /// that can be at risk in active auctions. Liquidations are blocked when this limit + /// would be exceeded. Governance can adjust this to control auction exposure. + /// Must be >= [`MaxPositionAmount`] so a single vault can always be liquidated. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// - [`Error::MaxLiquidationBelowMaxPosition`]: If new value < MaxPositionAmount. + /// + /// ## Events + /// + /// - [`Event::MaxLiquidationAmountUpdated`]: Emitted with old and new values. + #[pallet::call_index(12)] + #[pallet::weight(T::WeightInfo::set_max_liquidation_amount())] + pub fn set_max_liquidation_amount( + origin: OriginFor, + new_value: BalanceOf, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + // Must be >= MaxPositionAmount so a single vault can always be liquidated + if let Some(max_position) = MaxPositionAmount::::get() { + ensure!(new_value >= max_position, Error::::MaxLiquidationBelowMaxPosition); + } + let old_value = MaxLiquidationAmount::::get().unwrap_or_default(); + MaxLiquidationAmount::::put(new_value); + Self::deposit_event(Event::MaxLiquidationAmountUpdated { old_value, new_value }); + Ok(()) + } + + /// Force fee accrual on any vault. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed`. Anyone can poke any vault. + /// + /// ## Details + /// + /// Forces stability fee accrual on the specified vault. This is useful for: + /// - Updating inactive vault owners who still need to accrue fees + /// - Keeping vault state fresh for accurate collateralization queries + /// - Protocol monitoring and maintenance before liquidation checks + /// + /// ## Errors + /// + /// - [`Error::VaultNotFound`]: If the target vault does not exist. + /// - [`Error::VaultInLiquidation`]: If the vault is currently being liquidated. + /// + /// ## Events + /// + /// - [`Event::InterestAccrued`]: Emitted if interest was accrued. + #[pallet::call_index(13)] + #[pallet::weight(T::WeightInfo::poke())] + pub fn poke(origin: OriginFor, vault_owner: T::AccountId) -> DispatchResult { + ensure_signed(origin)?; + + Vaults::::try_mutate(&vault_owner, |maybe_vault| -> DispatchResult { + let vault = maybe_vault.as_mut().ok_or(Error::::VaultNotFound)?; + + ensure!(vault.status == VaultStatus::Healthy, Error::::VaultInLiquidation); + + Self::update_vault_fees(vault, &vault_owner, None)?; + + Ok(()) + }) + } + + /// Set the maximum total debt allowed in the system (debt ceiling). + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`]. Both Full and Emergency privilege levels + /// are supported with different capabilities: + /// - **Full (`GeneralAdmin`)**: Can set any value (raise or lower). + /// - **Emergency (`EmergencyAction`)**: Can only lower the ceiling, enabling fast-track + /// emergency response to oracle attacks without full governance approval. + /// + /// ## Details + /// + /// Sets the [`MaximumIssuance`] which is the system-wide cap on total pUSD issuance. + /// No new debt can be minted once this limit is reached. + /// + /// ## Errors + /// + /// - [`Error::CanOnlyLowerMaxDebt`]: If Emergency origin tries to raise the ceiling. + /// + /// ## Events + /// + /// - [`Event::MaximumIssuanceUpdated`]: Emitted with old and new values. + #[pallet::call_index(14)] + #[pallet::weight(T::WeightInfo::set_max_issuance())] + pub fn set_max_issuance(origin: OriginFor, amount: BalanceOf) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + let old_value = MaximumIssuance::::get().unwrap_or_default(); + + // Emergency can only lower the ceiling + if level == VaultsManagerLevel::Emergency { + ensure!(amount <= old_value, Error::::CanOnlyLowerMaxDebt); + } + + MaximumIssuance::::put(amount); + Self::deposit_event(Event::MaximumIssuanceUpdated { old_value, new_value: amount }); + Ok(()) + } + + /// Set the maximum pUSD debt that a single vault can have. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`MaxPositionAmount`] which limits the maximum debt a single vault + /// can accumulate. Must be <= [`MaxLiquidationAmount`] to ensure a single vault + /// can always be liquidated. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// - [`Error::MaxLiquidationBelowMaxPosition`]: If new value > MaxLiquidationAmount. + /// + /// ## Events + /// + /// - [`Event::MaxPositionAmountUpdated`]: Emitted with old and new values. + #[pallet::call_index(15)] + #[pallet::weight(T::WeightInfo::set_max_position_amount())] + pub fn set_max_position_amount( + origin: OriginFor, + new_value: BalanceOf, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + // Must be <= MaxLiquidationAmount so a single vault can always be liquidated + if let Some(max_liq) = MaxLiquidationAmount::::get() { + ensure!(new_value <= max_liq, Error::::MaxLiquidationBelowMaxPosition); + } + let old_value = MaxPositionAmount::::get().unwrap_or_default(); + MaxPositionAmount::::put(new_value); + Self::deposit_event(Event::MaxPositionAmountUpdated { old_value, new_value }); + Ok(()) + } + + /// Set the minimum deposit amount. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`MinimumDeposit`] which is the minimum amount of collateral (DOT) + /// required to create a vault. This prevents dust vaults and ensures vaults + /// have meaningful collateral. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// + /// ## Events + /// + /// - [`Event::MinimumDepositUpdated`]: Emitted with old and new values. + #[pallet::call_index(16)] + #[pallet::weight(T::WeightInfo::set_minimum_deposit())] + pub fn set_minimum_deposit( + origin: OriginFor, + new_value: BalanceOf, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + ensure!(!new_value.is_zero(), Error::::ZeroValueNotAllowed); + + let old_value = MinimumDeposit::::get().unwrap_or_default(); + MinimumDeposit::::put(new_value); + + Self::deposit_event(Event::MinimumDepositUpdated { old_value, new_value }); + Ok(()) + } + + /// Set the minimum mint amount. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`MinimumMint`] which is the minimum amount of pUSD that can be + /// minted in a single operation. This prevents dust debt positions. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// + /// ## Events + /// + /// - [`Event::MinimumMintUpdated`]: Emitted with old and new values. + #[pallet::call_index(17)] + #[pallet::weight(T::WeightInfo::set_minimum_mint())] + pub fn set_minimum_mint(origin: OriginFor, new_value: BalanceOf) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + ensure!(!new_value.is_zero(), Error::::ZeroValueNotAllowed); + + let old_value = MinimumMint::::get().unwrap_or_default(); + MinimumMint::::put(new_value); + + Self::deposit_event(Event::MinimumMintUpdated { old_value, new_value }); + Ok(()) + } + + /// Set the stale vault threshold. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`StaleVaultThreshold`] which is the duration (in milliseconds) + /// before a vault is considered stale for `on_idle` fee accrual processing. + /// Vaults unchanged for this duration will have their fees updated during + /// idle block processing. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// + /// ## Events + /// + /// - [`Event::StaleVaultThresholdUpdated`]: Emitted with old and new values. + #[pallet::call_index(18)] + #[pallet::weight(T::WeightInfo::set_stale_vault_threshold())] + pub fn set_stale_vault_threshold( + origin: OriginFor, + new_value: MomentOf, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + ensure!(!new_value.is_zero(), Error::::ZeroValueNotAllowed); + + let old_value = StaleVaultThreshold::::get().unwrap_or_default(); + StaleVaultThreshold::::put(new_value); + + Self::deposit_event(Event::StaleVaultThresholdUpdated { old_value, new_value }); + Ok(()) + } + + /// Set the oracle staleness threshold. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`] with [`VaultsManagerLevel::Full`] privilege + /// (typically `GeneralAdmin`). Emergency origin cannot modify this parameter. + /// + /// ## Details + /// + /// Sets the [`OracleStalenessThreshold`] which is the maximum age (in milliseconds) + /// of the oracle price before price-dependent operations are paused. When the + /// oracle price is older than this threshold, operations like mint, withdraw + /// with debt, and liquidate will fail. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If called by Emergency origin. + /// + /// ## Events + /// + /// - [`Event::OracleStalenessThresholdUpdated`]: Emitted with old and new values. + #[pallet::call_index(19)] + #[pallet::weight(T::WeightInfo::set_oracle_staleness_threshold())] + pub fn set_oracle_staleness_threshold( + origin: OriginFor, + new_value: MomentOf, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == VaultsManagerLevel::Full, Error::::InsufficientPrivilege); + ensure!(!new_value.is_zero(), Error::::ZeroValueNotAllowed); + + let old_value = OracleStalenessThreshold::::get().unwrap_or_default(); + OracleStalenessThreshold::::put(new_value); + + Self::deposit_event(Event::OracleStalenessThresholdUpdated { old_value, new_value }); + Ok(()) + } + } + + // Implement CollateralManager for the Vaults pallet + impl CollateralManager for Pallet { + type Balance = BalanceOf; + + fn get_dot_price() -> Option { + T::Oracle::get_price(&T::CollateralLocation::get()) + .map(|(price, _timestamp)| price) + .filter(|p| !p.is_zero()) + } + + fn execute_purchase( + buyer: &T::AccountId, + collateral_amount: BalanceOf, + payment: PaymentBreakdown>, + recipient: &T::AccountId, + vault_owner: &T::AccountId, + ) -> DispatchResult { + let burn_amount = payment.burn(); + let insurance_fund_amount = payment.insurance_fund(); + + // 1. Burn principal + interest pUSD from buyer + if !burn_amount.is_zero() { + T::StableAsset::burn_from( + buyer, + burn_amount, + Preservation::Expendable, + Precision::Exact, + Fortitude::Polite, + )?; + } + + // 2. Transfer penalty to Insurance Fund (includes keeper's share temporarily) + // Keeper will be paid from IF at auction completion. + if !insurance_fund_amount.is_zero() { + T::StableAsset::transfer( + buyer, + &T::InsuranceFund::get(), + insurance_fund_amount, + Preservation::Expendable, + )?; + } + + // 3. Release collateral from Seized hold and transfer to recipient + if vault_owner == recipient { + T::Collateral::release( + &HoldReason::Seized.into(), + vault_owner, + collateral_amount, + Precision::Exact, + )?; + } else { + T::Collateral::transfer_on_hold( + &HoldReason::Seized.into(), + vault_owner, + recipient, + collateral_amount, + Precision::Exact, + Restriction::Free, + Fortitude::Polite, + )?; + } + + let payment_total = payment.total(); + if !payment_total.is_zero() { + CurrentLiquidationAmount::::try_mutate(|current| -> DispatchResult { + *current = current + .checked_sub(&payment_total) + .ok_or(Error::::ArithmeticUnderflow)?; + Ok(()) + })?; + } + + Self::deposit_event(Event::AuctionDebtCollected { amount: payment.total() }); + + Ok(()) + } + + fn complete_auction( + vault_owner: &T::AccountId, + remaining_collateral: BalanceOf, + remaining_debt: DebtComponents>, + keeper: &T::AccountId, + keeper_incentive: BalanceOf, + ) -> DispatchResult { + // Return excess collateral to vault owner + if !remaining_collateral.is_zero() { + T::Collateral::release( + &HoldReason::Seized.into(), + vault_owner, + remaining_collateral, + Precision::Exact, + )?; + } + + // Decrement CurrentLiquidationAmount by the full unresolved remainder + let remaining_total = remaining_debt.total(); + if !remaining_total.is_zero() { + CurrentLiquidationAmount::::try_mutate(|current| -> DispatchResult { + *current = current + .checked_sub(&remaining_total) + .ok_or(Error::::ArithmeticUnderflow)?; + Ok(()) + })?; + } + + // Bad debt = principal + interest (unbacked pUSD that was minted). + // Penalty is lost protocol revenue, not bad debt. + let shortfall = remaining_debt.principal.saturating_add(remaining_debt.interest); + if !shortfall.is_zero() { + BadDebt::::mutate(|bad_debt| { + bad_debt.saturating_accrue(shortfall); + }); + + Self::deposit_event(Event::BadDebtAccrued { + owner: vault_owner.clone(), + amount: shortfall, + }); + + log::warn!( + target: LOG_TARGET, + "Auction shortfall: owner={:?}, shortfall={:?}, penalty_loss={:?}", + vault_owner, + shortfall, + remaining_debt.penalty, + ); + } + + if !remaining_total.is_zero() { + Self::deposit_event(Event::AuctionShortfall { shortfall: remaining_total }); + } + + Vaults::::remove(vault_owner); + Self::deposit_event(Event::VaultClosed { owner: vault_owner.clone() }); + + // Keeper rewards are best-effort and must not block liquidation finalization. + if !keeper_incentive.is_zero() { + if T::StableAsset::transfer( + &T::InsuranceFund::get(), + keeper, + keeper_incentive, + Preservation::Expendable, + ) + .is_err() + { + log::warn!( + target: LOG_TARGET, + "Keeper incentive payment failed: keeper={:?}, amount={:?}", + keeper, + keeper_incentive + ); + Self::deposit_event(Event::KeeperIncentivePaymentFailed { + keeper: keeper.clone(), + amount: keeper_incentive, + }); + } + } + + Ok(()) + } + + /// Get the Insurance Fund's pUSD balance. + /// + /// Used by auctions pallet to check if surplus auctions can be started. + fn get_insurance_fund_balance() -> BalanceOf { + T::StableAsset::balance(&T::InsuranceFund::get()) + } + + /// Get the total pUSD supply. + /// + /// Used with `get_insurance_fund_balance()` to calculate whether the + /// Insurance Fund exceeds the surplus auction threshold. + fn get_total_pusd_supply() -> BalanceOf { + T::StableAsset::total_issuance() + } + + fn execute_surplus_purchase( + buyer: &T::AccountId, + recipient: &T::AccountId, + pusd_amount: BalanceOf, + collateral_amount: BalanceOf, + ) -> DispatchResult { + // 1. Transfer pUSD from Insurance Fund to recipient + if !pusd_amount.is_zero() { + T::StableAsset::transfer( + &T::InsuranceFund::get(), + recipient, + pusd_amount, + Preservation::Expendable, + )?; + } + + // 2. Withdraw collateral from buyer and let FeeHandler decide what to do with it + if !collateral_amount.is_zero() { + let credit = T::Collateral::withdraw( + buyer, + collateral_amount, + Precision::Exact, + Preservation::Preserve, + Fortitude::Polite, + )?; + T::FeeHandler::on_unbalanced(credit); + } + + Ok(()) + } + + /// Transfer surplus pUSD from Insurance Fund via `SurplusHandler`. + /// + /// Used in `DirectTransfer` mode to send surplus directly to the configured + /// destination (typically Treasury) without going through an auction. + fn transfer_surplus(amount: BalanceOf) -> DispatchResult { + // Withdraw pUSD from Insurance Fund creating a credit + let credit = T::StableAsset::withdraw( + &T::InsuranceFund::get(), + amount, + Precision::Exact, + Preservation::Expendable, + Fortitude::Polite, + )?; + + // Let the SurplusHandler decide where to send it + T::SurplusHandler::on_unbalanced(credit); + + Ok(()) + } + } + + // Test-only helper functions for internal logic testing + #[cfg(test)] + impl Pallet { + /// Reduce CurrentLiquidationAmount (simulates debt collection in auction). + /// Test-only helper for isolated unit testing. + pub fn test_reduce_liquidation_amount(amount: BalanceOf) -> DispatchResult { + CurrentLiquidationAmount::::try_mutate(|current| -> DispatchResult { + *current = current.checked_sub(&amount).ok_or(Error::::ArithmeticUnderflow)?; + Ok(()) + })?; + Self::deposit_event(Event::AuctionDebtCollected { amount }); + Ok(()) + } + + /// Record auction shortfall (simulates auction completion with remaining debt). + /// Test-only helper for isolated unit testing. + pub fn test_record_shortfall( + vault_owner: T::AccountId, + remaining_debt: DebtComponents>, + ) -> DispatchResult { + let remaining_total = remaining_debt.total(); + if !remaining_total.is_zero() { + CurrentLiquidationAmount::::try_mutate(|current| -> DispatchResult { + *current = current + .checked_sub(&remaining_total) + .ok_or(Error::::ArithmeticUnderflow)?; + Ok(()) + })?; + } + let shortfall = remaining_debt.principal.saturating_add(remaining_debt.interest); + if !shortfall.is_zero() { + BadDebt::::mutate(|bad_debt| { + bad_debt.saturating_accrue(shortfall); + }); + Self::deposit_event(Event::BadDebtAccrued { + owner: vault_owner, + amount: shortfall, + }); + } + if !remaining_total.is_zero() { + Self::deposit_event(Event::AuctionShortfall { shortfall: remaining_total }); + } + Ok(()) + } + } + + // Helper functions + impl Pallet { + /// Internal utility for all pUSD minting operations. + /// + /// + /// # Arguments + /// * `to` - Account to receive the minted pUSD + /// * `amount` - Amount of pUSD to mint + /// * `purpose` - The purpose of this mint (affects which checks are enforced) + /// + /// # Errors + /// * [`Error::ExceedsMaxDebt`] - For `Principal` mints when ceiling would be exceeded + pub(crate) fn do_mint( + to: &T::AccountId, + amount: BalanceOf, + purpose: MintPurpose, + ) -> DispatchResult { + if amount.is_zero() { + return Ok(()); + } + + // For principal mints, strictly enforce the system debt ceiling + if matches!(purpose, MintPurpose::Principal) { + let total_issuance = T::StableAsset::total_issuance(); + ensure!( + total_issuance.saturating_add(amount) <= + MaximumIssuance::::get().ok_or(Error::::NotConfigured)?, + Error::::ExceedsMaxDebt + ); + } + + // Execute the mint + T::StableAsset::mint_into(to, amount)?; + + Ok(()) + } + + /// Calculate collateralization ratio from explicit collateral and debt values. + /// + /// Formula: + /// ```text + /// collateral_value = collateral × price + /// debt = principal + accrued_interest + /// ratio = collateral_value / debt + /// ``` + /// + /// Returns the ratio as `FixedU128` (e.g., 150% = 1.5). + /// If debt is zero, returns `FixedU128::max_value()` (infinite CR = healthy). + pub(crate) fn calculate_collateralization_ratio( + collateral: BalanceOf, + debt: BalanceOf, + ) -> Result { + if debt.is_zero() { + return Ok(FixedU128::max_value()); + } + + // Get fresh normalized price. + let price = Self::get_fresh_price()?; + + // Convert collateral to stablecoin value using FixedPointOperand + let collateral_value = price.saturating_mul_int(collateral); + + // Calculate ratio: collateral_value / debt (both in stablecoin smallest units) + let ratio = FixedU128::saturating_from_rational(collateral_value, debt); + + Ok(ratio) + } + + /// Get the collateralization ratio for a vault. + /// + /// Formula: + /// ```text + /// debt = principal + accrued_interest + /// collateralization_ratio = collateral × price / debt + /// ``` + /// + /// Returns `FixedU128::max_value()` if the vault has no debt (infinite CR = healthy). + pub(crate) fn get_collateralization_ratio( + vault: &Vault, + who: &T::AccountId, + ) -> Result { + let held_collateral = vault.get_held_collateral(who); + let total_debt = vault.total_debt()?; + Self::calculate_collateralization_ratio(held_collateral, total_debt) + } + + /// Close a vault: verify no debt, release collateral, emit events. + /// + /// Requires both `principal` and `accrued_interest` to be zero. + /// Users must call `repay()` to settle all debt before closing. + /// + /// - All collateral released to vault's owner + /// - Events emitted ([`Event::CollateralWithdrawn`], [`Event::VaultClosed`]) + fn do_close_vault(vault: &Vault, who: &T::AccountId) -> DispatchResult { + // Debt must be fully repaid (both principal and accrued interest) + ensure!( + vault.principal.is_zero() && vault.accrued_interest.is_zero(), + Error::::VaultHasDebt + ); + + // Release all collateral + let released = T::Collateral::release_all( + &HoldReason::VaultDeposit.into(), + who, + Precision::BestEffort, + )?; + + if !released.is_zero() { + Self::deposit_event(Event::CollateralWithdrawn { + owner: who.clone(), + amount: released, + }); + } + + Self::deposit_event(Event::VaultClosed { owner: who.clone() }); + + Ok(()) + } + + /// Update the accrued interest for a vault based on elapsed time. + /// + /// Calculates interest in pUSD, mints it to the Insurance Fund, and adds + /// the amount to the vault's `accrued_interest`. This "mint-on-accrual" model + /// ensures total pUSD supply reflects all outstanding obligations. + /// + /// Uses actual timestamps for accurate time-based interest calculation. + /// If [`StabilityFee`] was recently changed, accrual is split at the change + /// boundary so the new rate is not applied retroactively. + /// Emits an `InterestAccrued` event if interest was accrued. + /// + /// # Parameters + /// - `vault`: The vault to update + /// - `who`: The vault owner (for event emission) + /// - `now`: Optional timestamp; if `None`, fetches current time + /// + /// # Errors + /// Returns an error if minting to the Insurance Fund fails. + pub(crate) fn update_vault_fees( + vault: &mut Vault, + who: &T::AccountId, + now: Option>, + ) -> DispatchResult { + let now = now.unwrap_or_else(T::TimeProvider::now); + if now <= vault.last_fee_update { + return Ok(()); + } + + let stability_fee = StabilityFee::::get().ok_or(Error::::NotConfigured)?; + + // Calculate accrued interest, splitting across fee change boundary if needed. + let accrued = match PreviousStabilityFee::::get() { + Some((previous_fee, changed_at)) + if vault.last_fee_update < changed_at && changed_at < now => + { + let old_elapsed = changed_at.saturating_sub(vault.last_fee_update); + let new_elapsed = now.saturating_sub(changed_at); + Self::compute_interest(vault.principal, previous_fee, old_elapsed) + .saturating_add(Self::compute_interest( + vault.principal, + stability_fee, + new_elapsed, + )) + }, + _ => { + let millis_elapsed = now.saturating_sub(vault.last_fee_update); + Self::compute_interest(vault.principal, stability_fee, millis_elapsed) + }, + }; + + if accrued.is_zero() { + // If the current fee produces zero annual interest (fee or principal is + // zero), advance timestamp since no interest can accrue regardless of + // elapsed time. Otherwise, don't advance to preserve precision on short + // time intervals. + if stability_fee.mul_floor(vault.principal).is_zero() { + vault.last_fee_update = now; + } + return Ok(()); + } + + Self::do_mint(&T::InsuranceFund::get(), accrued, MintPurpose::Interest)?; + + vault.accrued_interest.saturating_accrue(accrued); + vault.last_fee_update = now; + + Self::deposit_event(Event::InterestAccrued { owner: who.clone(), amount: accrued }); + + Ok(()) + } + + /// Compute interest for a given principal, fee rate, and elapsed time. + fn compute_interest( + principal: BalanceOf, + fee: Permill, + millis_elapsed: MomentOf, + ) -> BalanceOf { + let annual_interest = fee.mul_floor(principal); + if annual_interest.is_zero() { + return Zero::zero(); + } + let elapsed_ratio = FixedU128::saturating_from_rational( + millis_elapsed.saturated_into::(), + MILLIS_PER_YEAR, + ); + elapsed_ratio.saturating_mul_int(annual_interest) + } + + /// Base weight for `on_idle` overhead. + /// + /// Includes: + /// - Reading the cursor (1 read) + /// - Writing cursor update (1 write, worst case) + pub(crate) fn on_idle_base_weight() -> Weight { + T::DbWeight::get().reads_writes(1, 1) + } + + /// Process one vault during `on_idle`. + /// + /// Only healthy stale vaults are updated. Failures are logged and skipped so the + /// cursor can continue progressing across the vault set. + pub(crate) fn process_on_idle_vault( + owner: T::AccountId, + mut vault: Vault, + current_timestamp: MomentOf, + stale_threshold: MomentOf, + ) { + if vault.status != VaultStatus::Healthy { + return; + } + + let time_since = current_timestamp.saturating_sub(vault.last_fee_update); + if time_since < stale_threshold { + return; + } + + if let Err(e) = Self::update_vault_fees(&mut vault, &owner, Some(current_timestamp)) { + log::warn!( + target: LOG_TARGET, + "on_idle: failed to update vault fees for {:?}: {:?}", + owner, + e + ); + // Skip this vault for the current pass; it can be revisited on a later full pass. + return; + } + + log::debug!( + target: LOG_TARGET, + "on_idle: updated stale vault fees for {:?}, time_since={:?}ms", + owner, + time_since + ); + Vaults::::insert(&owner, vault); + } + + /// Benchmarked weight to process one stale vault. + /// + /// This is derived from the `on_idle_one_vault` benchmark which measures + /// the worst case: a stale vault with debt requiring fee calculation. + pub(crate) fn on_idle_per_vault_weight() -> Weight { + T::WeightInfo::on_idle_one_vault().saturating_sub(Self::on_idle_base_weight()) + } + + /// Get a price from the oracle. + /// + /// Returns the price if it's available, non-zero, and within the staleness threshold. + /// + /// # Errors + /// - `PriceNotAvailable`: Oracle returned None or zero price + /// - `OracleStale`: Price timestamp is older than `OracleStalenessThreshold` + pub(crate) fn get_fresh_price() -> Result { + let (price, price_timestamp) = T::Oracle::get_price(&T::CollateralLocation::get()) + .filter(|(p, _)| !p.is_zero()) + .ok_or(Error::::PriceNotAvailable)?; + + let current_time = T::TimeProvider::now(); + let threshold = + OracleStalenessThreshold::::get().ok_or(Error::::NotConfigured)?; + let price_age = current_time.saturating_sub(price_timestamp); + + ensure!(price_age <= threshold, Error::::OracleStale); + + Ok(price) + } + + /// Ensure the Insurance Fund account exists by incrementing its provider count. + /// + /// This is called at genesis and on runtime upgrade. + /// It's idempotent - calling it multiple times is safe. + /// + /// By using `inc_providers`, the account can receive any amount including + /// those below the existential deposit (ED), preventing potential issues + /// where transfers to the Insurance Fund could fail if it was reaped. + pub(crate) fn ensure_insurance_fund_exists() { + let insurance_fund = T::InsuranceFund::get(); + if !frame_system::Pallet::::account_exists(&insurance_fund) { + frame_system::Pallet::::inc_providers(&insurance_fund); + log::debug!( + target: LOG_TARGET, + "Created Insurance Fund account: {:?}", + insurance_fund + ); + } + } + } + + #[cfg(any(test, feature = "try-runtime"))] + impl Pallet { + /// Validate all pallet invariants. + /// + /// Called by `try_state` hook and `build_and_execute` in tests. + pub(crate) fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { + Self::try_state_params_configured()?; + Self::try_state_param_relationships()?; + Self::try_state_vault_invariants()?; + Self::try_state_system_invariants()?; + Ok(()) + } + + /// Ensure all 11 configurable parameters are set. + fn try_state_params_configured() -> Result<(), sp_runtime::TryRuntimeError> { + ensure!( + MinimumCollateralizationRatio::::get().is_some(), + "MinimumCollateralizationRatio not configured" + ); + ensure!( + InitialCollateralizationRatio::::get().is_some(), + "InitialCollateralizationRatio not configured" + ); + ensure!(StabilityFee::::get().is_some(), "StabilityFee not configured"); + ensure!(LiquidationPenalty::::get().is_some(), "LiquidationPenalty not configured"); + ensure!(MaximumIssuance::::get().is_some(), "MaximumIssuance not configured"); + ensure!( + MaxLiquidationAmount::::get().is_some(), + "MaxLiquidationAmount not configured" + ); + ensure!(MaxPositionAmount::::get().is_some(), "MaxPositionAmount not configured"); + ensure!(MinimumDeposit::::get().is_some(), "MinimumDeposit not configured"); + ensure!(MinimumMint::::get().is_some(), "MinimumMint not configured"); + ensure!( + StaleVaultThreshold::::get().is_some(), + "StaleVaultThreshold not configured" + ); + ensure!( + OracleStalenessThreshold::::get().is_some(), + "OracleStalenessThreshold not configured" + ); + Ok(()) + } + + /// Ensure parameter cross-relationships hold. + fn try_state_param_relationships() -> Result<(), sp_runtime::TryRuntimeError> { + if let (Some(min_ratio), Some(init_ratio)) = ( + MinimumCollateralizationRatio::::get(), + InitialCollateralizationRatio::::get(), + ) { + ensure!( + init_ratio >= min_ratio, + "InitialCollateralizationRatio must be >= MinimumCollateralizationRatio" + ); + } + if let Some(min_ratio) = MinimumCollateralizationRatio::::get() { + ensure!( + min_ratio > FixedU128::one(), + "MinimumCollateralizationRatio must be > 100%" + ); + } + if let (Some(max_position), Some(max_liquidation)) = + (MaxPositionAmount::::get(), MaxLiquidationAmount::::get()) + { + ensure!( + max_position <= max_liquidation, + "MaxPositionAmount must be <= MaxLiquidationAmount" + ); + } + Ok(()) + } + + /// Ensure all vaults satisfy basic invariants. + fn try_state_vault_invariants() -> Result<(), sp_runtime::TryRuntimeError> { + for (owner, vault) in Vaults::::iter() { + if vault.status == VaultStatus::InLiquidation { + let vault_deposit_hold = + T::Collateral::balance_on_hold(&HoldReason::VaultDeposit.into(), &owner); + ensure!( + vault_deposit_hold.is_zero(), + "Vault in liquidation should not have VaultDeposit hold" + ); + } + + if vault.status == VaultStatus::Healthy { + let total_debt = vault + .principal + .checked_add(&vault.accrued_interest) + .unwrap_or(Bounded::max_value()); + if !total_debt.is_zero() { + let collateral = vault.get_held_collateral(&owner); + ensure!( + !collateral.is_zero(), + "Healthy vault with debt must have non-zero collateral" + ); + } + } + } + Ok(()) + } + + /// Ensure system-level accumulators are consistent. + fn try_state_system_invariants() -> Result<(), sp_runtime::TryRuntimeError> { + // CurrentLiquidationAmount must not exceed MaxLiquidationAmount + if let Some(max_liq) = MaxLiquidationAmount::::get() { + let current_liq = CurrentLiquidationAmount::::get(); + ensure!( + current_liq <= max_liq, + "CurrentLiquidationAmount exceeds MaxLiquidationAmount" + ); + } + + // Total pUSD supply should respect MaximumIssuance. + // Note: interest mints bypass MaximumIssuance by design (MintPurpose::Interest), + // so total supply can slightly exceed the ceiling. We log a warning instead + // of failing for this check. + if let Some(max_issuance) = MaximumIssuance::::get() { + let total_supply = T::StableAsset::total_issuance(); + if total_supply > max_issuance { + log::warn!( + target: LOG_TARGET, + "Total pUSD supply ({:?}) exceeds MaximumIssuance ({:?}). \ + This can happen due to interest mints.", + total_supply, + max_issuance, + ); + } + } + + Ok(()) + } + } +} diff --git a/substrate/frame/vaults/src/migrations/mod.rs b/substrate/frame/vaults/src/migrations/mod.rs new file mode 100644 index 0000000000000..d01e33889af20 --- /dev/null +++ b/substrate/frame/vaults/src/migrations/mod.rs @@ -0,0 +1,22 @@ +// This file is part of Substrate. + +// Copyright (C) Amforc AG. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Migrations for the vaults pallet. + +pub mod v1; + +pub use v1::{InitialVaultsConfig, MigrateV0ToV1}; diff --git a/substrate/frame/vaults/src/migrations/v1.rs b/substrate/frame/vaults/src/migrations/v1.rs new file mode 100644 index 0000000000000..f76e446c1cdac --- /dev/null +++ b/substrate/frame/vaults/src/migrations/v1.rs @@ -0,0 +1,286 @@ +// This file is part of Substrate. + +// Copyright (C) Amforc AG. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Migration to V1: Initialize pallet parameters. +//! +//! This migration sets initial values for all pallet parameters when deploying +//! to an existing chain. It should be included in the runtime's +//! migration list when first adding the vaults pallet. +//! +//! # Usage +//! +//! Include in the runtime migrations: +//! +//! ```ignore +//! pub type Migrations = ( +//! pallet_vaults::migrations::v1::MigrateV0ToV1, +//! // ... other migrations +//! ); +//! ``` +//! +//! Where `VaultsInitialConfig` implements [`InitialVaultsConfig`]: +//! +//! ```ignore +//! pub struct VaultsInitialConfig; +//! impl pallet_vaults::migrations::v1::InitialVaultsConfig for VaultsInitialConfig { +//! fn minimum_collateralization_ratio() -> FixedU128 { +//! FixedU128::saturating_from_rational(150, 100) // 150% +//! } +//! fn initial_collateralization_ratio() -> FixedU128 { +//! FixedU128::saturating_from_rational(175, 100) // 175% +//! } +//! // ... etc +//! } +//! ``` + +use crate::{ + pallet::{ + InitialCollateralizationRatio, LiquidationPenalty, MaxLiquidationAmount, MaxPositionAmount, + MaximumIssuance, MinimumCollateralizationRatio, MinimumDeposit, MinimumMint, + OracleStalenessThreshold, StabilityFee, StaleVaultThreshold, + }, + BalanceOf, Config, MomentOf, Pallet, +}; +use frame_support::{pallet_prelude::*, traits::UncheckedOnRuntimeUpgrade}; +use sp_runtime::{traits::SaturatedConversion, FixedU128, Permill}; + +#[cfg(feature = "try-runtime")] +use sp_runtime::TryRuntimeError; + +/// Configuration trait for initial parameter values. +/// +/// Implement this trait in the runtime to specify the initial values +/// for vaults pallet parameters. +pub trait InitialVaultsConfig { + /// Minimum collateralization ratio (e.g., 150% = 1.5). + /// Below this ratio, vaults become eligible for liquidation. + fn minimum_collateralization_ratio() -> FixedU128; + + /// Initial collateralization ratio (e.g., 175% = 1.75). + /// Required when minting new debt. Must be >= minimum ratio. + fn initial_collateralization_ratio() -> FixedU128; + + /// Annual stability fee as Permill (e.g., 5% = `Permill::from_percent(5)`). + fn stability_fee() -> Permill; + + /// Liquidation penalty as Permill (e.g., 13% = `Permill::from_percent(13)`). + fn liquidation_penalty() -> Permill; + + /// Maximum total pUSD debt allowed in the system. + fn maximum_issuance() -> BalanceOf; + + /// Maximum pUSD at risk in active auctions. + fn max_liquidation_amount() -> BalanceOf; + + /// Maximum pUSD debt for a single vault. + fn max_position_amount() -> BalanceOf; + + /// Minimum DOT required to create a vault. + fn minimum_deposit() -> BalanceOf; + + /// Minimum pUSD amount that can be minted in a single operation. + fn minimum_mint() -> BalanceOf; + + /// Milliseconds before a vault is considered stale for on_idle processing. + fn stale_vault_threshold() -> u64; + + /// Maximum age (milliseconds) of oracle price before operations pause. + fn oracle_staleness_threshold() -> u64; +} + +/// Migration logic for V0 -> V1. +/// +/// This struct implements the actual migration. It is wrapped by +/// [`MigrateV0ToV1`] which uses [`VersionedMigration`] to handle version +/// checking and updating automatically. +pub struct VersionUncheckedMigrateV0ToV1(core::marker::PhantomData<(T, I)>); + +impl> UncheckedOnRuntimeUpgrade + for VersionUncheckedMigrateV0ToV1 +{ + fn on_runtime_upgrade() -> Weight { + log::info!( + target: crate::pallet::LOG_TARGET, + "Running MigrateToV1: initializing vaults pallet parameters" + ); + + // Set all parameters + MinimumCollateralizationRatio::::put(I::minimum_collateralization_ratio()); + InitialCollateralizationRatio::::put(I::initial_collateralization_ratio()); + StabilityFee::::put(I::stability_fee()); + LiquidationPenalty::::put(I::liquidation_penalty()); + MaximumIssuance::::put(I::maximum_issuance()); + MaxLiquidationAmount::::put(I::max_liquidation_amount()); + MaxPositionAmount::::put(I::max_position_amount()); + MinimumDeposit::::put(I::minimum_deposit()); + MinimumMint::::put(I::minimum_mint()); + StaleVaultThreshold::::put(I::stale_vault_threshold().saturated_into::>()); + OracleStalenessThreshold::::put( + I::oracle_staleness_threshold().saturated_into::>(), + ); + + // Ensure Insurance Fund account exists (1 read + potentially 1 write) + Pallet::::ensure_insurance_fund_exists(); + + log::info!( + target: crate::pallet::LOG_TARGET, + "MigrateToV1 complete" + ); + + // 11 writes (parameters) + 1 read + 1 write (insurance fund account check/creation) + T::DbWeight::get().reads_writes(1, 12) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, TryRuntimeError> { + // VersionedMigration ensures we only run when version is 0 + Ok(Vec::new()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), TryRuntimeError> { + // Verify parameters are set (non-zero where applicable) + ensure!( + MinimumCollateralizationRatio::::get().is_some(), + "MinimumCollateralizationRatio not set" + ); + ensure!( + InitialCollateralizationRatio::::get().is_some(), + "InitialCollateralizationRatio not set" + ); + // StabilityFee and LiquidationPenalty can legitimately be zero + ensure!(MaximumIssuance::::get().is_some(), "MaximumIssuance not set"); + ensure!(MaxLiquidationAmount::::get().is_some(), "MaxLiquidationAmount not set"); + ensure!(MaxPositionAmount::::get().is_some(), "MaxPositionAmount not set"); + ensure!(MinimumDeposit::::get().is_some(), "MinimumDeposit not set"); + ensure!(MinimumMint::::get().is_some(), "MinimumMint not set"); + ensure!(StaleVaultThreshold::::get().is_some(), "StaleVaultThreshold not set"); + ensure!(OracleStalenessThreshold::::get().is_some(), "OracleStalenessThreshold not set"); + + Ok(()) + } +} + +/// [`UncheckedOnRuntimeUpgrade`] implementation [`VersionUncheckedMigrateV0ToV1`] wrapped in a +/// [`VersionedMigration`](frame_support::migrations::VersionedMigration), which ensures that: +/// - The migration only runs once when the on-chain storage version is 0 +/// - The on-chain storage version is updated to `1` after the migration executes +/// - Reads/Writes from checking/setting the on-chain storage version are accounted for +pub type MigrateV0ToV1 = frame_support::migrations::VersionedMigration< + 0, // The migration will only execute when the on-chain storage version is 0 + 1, // The on-chain storage version will be set to 1 after the migration is complete + VersionUncheckedMigrateV0ToV1, + Pallet, + ::DbWeight, +>; + +#[cfg(test)] +mod tests { + use super::*; + use crate::mock::{new_test_ext, Test}; + use frame_support::traits::StorageVersion; + use sp_runtime::FixedPointNumber; + + /// Test implementation of InitialVaultsConfig + pub struct TestVaultsConfig; + impl InitialVaultsConfig for TestVaultsConfig { + fn minimum_collateralization_ratio() -> FixedU128 { + FixedU128::saturating_from_rational(180, 100) // 180% + } + fn initial_collateralization_ratio() -> FixedU128 { + FixedU128::saturating_from_rational(200, 100) // 200% + } + fn stability_fee() -> Permill { + Permill::from_percent(4) + } + fn liquidation_penalty() -> Permill { + Permill::from_percent(13) + } + fn maximum_issuance() -> BalanceOf { + 20_000_000_000_000 // 20M with 6 decimals + } + fn max_liquidation_amount() -> BalanceOf { + 20_000_000_000_000 + } + fn max_position_amount() -> BalanceOf { + 10_000_000_000_000 + } + fn minimum_deposit() -> BalanceOf { + 100_000_000_000_000 // 100 DOT with 10 decimals + } + fn minimum_mint() -> BalanceOf { + 5_000_000 // 5 pUSD with 6 decimals + } + fn stale_vault_threshold() -> u64 { + 14_400_000 // 4 hours in ms + } + fn oracle_staleness_threshold() -> u64 { + 3_600_000 // 1 hour in ms + } + } + + #[test] + fn migration_v0_to_v1_works() { + new_test_ext().execute_with(|| { + // Clear storage to simulate pre-migration state (v0) + StorageVersion::new(0).put::>(); + MinimumCollateralizationRatio::::kill(); + InitialCollateralizationRatio::::kill(); + StabilityFee::::kill(); + LiquidationPenalty::::kill(); + MaximumIssuance::::kill(); + MaxLiquidationAmount::::kill(); + MaxPositionAmount::::kill(); + MinimumDeposit::::kill(); + MinimumMint::::kill(); + StaleVaultThreshold::::kill(); + OracleStalenessThreshold::::kill(); + + // Verify storage is empty before migration + assert!(MinimumCollateralizationRatio::::get().is_none()); + assert!(InitialCollateralizationRatio::::get().is_none()); + assert!(MaximumIssuance::::get().is_none()); + assert!(MinimumDeposit::::get().is_none()); + assert!(MinimumMint::::get().is_none()); + assert!(StaleVaultThreshold::::get().is_none()); + assert!(OracleStalenessThreshold::::get().is_none()); + + // Run migration + let _weight = + VersionUncheckedMigrateV0ToV1::::on_runtime_upgrade(); + + // Verify all parameters are set correctly + assert_eq!( + MinimumCollateralizationRatio::::get(), + Some(FixedU128::saturating_from_rational(180, 100)) + ); + assert_eq!( + InitialCollateralizationRatio::::get(), + Some(FixedU128::saturating_from_rational(200, 100)) + ); + assert_eq!(StabilityFee::::get(), Some(Permill::from_percent(4))); + assert_eq!(LiquidationPenalty::::get(), Some(Permill::from_percent(13))); + assert_eq!(MaximumIssuance::::get(), Some(20_000_000_000_000)); + assert_eq!(MaxLiquidationAmount::::get(), Some(20_000_000_000_000)); + assert_eq!(MaxPositionAmount::::get(), Some(10_000_000_000_000)); + assert_eq!(MinimumDeposit::::get(), Some(100_000_000_000_000)); + assert_eq!(MinimumMint::::get(), Some(5_000_000)); + assert_eq!(StaleVaultThreshold::::get(), Some(14_400_000)); + assert_eq!(OracleStalenessThreshold::::get(), Some(3_600_000)); + }); + } +} diff --git a/substrate/frame/vaults/src/mock.rs b/substrate/frame/vaults/src/mock.rs new file mode 100644 index 0000000000000..b7f18d008508a --- /dev/null +++ b/substrate/frame/vaults/src/mock.rs @@ -0,0 +1,490 @@ +// This file is part of Substrate. + +// Copyright (C) Amforc AG. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +use crate::{AuctionsHandler, Location, ProvidePrice}; +pub use frame_support::weights::Weight; +use frame_support::{ + derive_impl, parameter_types, + traits::{ + fungible::{Balanced as FungibleBalanced, Credit as FungibleCredit, ItemOf}, + tokens::imbalance::{OnUnbalanced, ResolveTo}, + AsEnsureOriginWithArg, ConstU128, EnsureOrigin, Get, Hooks, Time, + }, +}; +use frame_system::{EnsureRoot, EnsureSigned, GenesisConfig, RawOrigin}; +use sp_io::TestExternalities as TestState; +use sp_runtime::{ + traits::{CheckedDiv, Zero}, + BuildStorage, DispatchError, FixedPointNumber, FixedU128, Permill, Saturating, +}; +use std::cell::RefCell; + +// Test accounts +pub const ALICE: u64 = 1; +pub const BOB: u64 = 2; +pub const CHARLIE: u64 = 3; +pub const INSURANCE_FUND: u64 = 100; + +pub const STABLECOIN_ASSET_ID: u32 = 1; // pUSD + +// Initial balances for testing (DOT has 10 decimals) +pub const INITIAL_BALANCE: u128 = 1_000 * 10_000_000_000; // 1000 DOT + +// Decimal configuration for price normalization +const COLLATERAL_DECIMALS: u32 = 10; // DOT has 10 decimals +const STABLECOIN_DECIMALS: u32 = 6; // pUSD has 6 decimals + +// Thread-local storage for mock time (milliseconds since Unix epoch) +thread_local! { + static MOCK_TIME: RefCell = const { RefCell::new(0) }; + // Default: 1 DOT = 4.21 USD (realistic price for better edge case testing) + static MOCK_RAW_PRICE: RefCell> = const { RefCell::new(Some(FixedU128::from_rational(421, 100))) }; + // Counter for mock auction IDs + static MOCK_AUCTION_ID: RefCell = const { RefCell::new(0) }; + // Force `start_auction` to fail for liquidation atomicity tests. + static FAIL_AUCTION_START: RefCell = const { RefCell::new(false) }; + // Timestamp when mock oracle price was last updated (milliseconds since Unix epoch) + // Default: 0 (will be set to current timestamp on first price set or in test setup) + static MOCK_PRICE_TIMESTAMP: RefCell = const { RefCell::new(0) }; + // Max items to process per on_idle call (default: unlimited) + static MAX_ON_IDLE_ITEMS: RefCell = const { RefCell::new(u32::MAX) }; +} + +pub struct MockMaxOnIdleItems; + +impl Get for MockMaxOnIdleItems { + fn get() -> u32 { + MAX_ON_IDLE_ITEMS.with(|v| *v.borrow()) + } +} + +pub fn set_max_on_idle_items(n: u32) { + MAX_ON_IDLE_ITEMS.with(|v| *v.borrow_mut() = n); +} + +pub fn set_mock_auction_start_failure(should_fail: bool) { + FAIL_AUCTION_START.with(|v| *v.borrow_mut() = should_fail); +} + +/// Mock Timestamp implementation for testing. +pub struct MockTimestamp; + +impl Time for MockTimestamp { + type Moment = u64; + + fn now() -> Self::Moment { + MOCK_TIME.with(|t| *t.borrow()) + } +} + +impl MockTimestamp { + /// Set the current timestamp (in milliseconds). + pub fn set_timestamp(val: u64) { + MOCK_TIME.with(|t| *t.borrow_mut() = val); + } + + /// Get the current timestamp (in milliseconds). + pub fn get() -> u64 { + MOCK_TIME.with(|t| *t.borrow()) + } +} + +/// Set the mock oracle price for testing (in USD per 1 whole collateral unit) +/// The oracle will automatically convert this to normalized format. +/// Also updates the price timestamp to the current time. +pub fn set_mock_price(price: Option) { + MOCK_RAW_PRICE.with(|p| *p.borrow_mut() = price); + // Update timestamp to current time when price is set + if price.is_some() { + MOCK_PRICE_TIMESTAMP.with(|t| { + *t.borrow_mut() = MockTimestamp::get(); + }); + } +} + +/// Set the mock oracle price timestamp directly for testing staleness. +/// Use this to simulate stale oracle scenarios. +pub fn set_mock_price_timestamp(timestamp: u64) { + MOCK_PRICE_TIMESTAMP.with(|t| *t.borrow_mut() = timestamp); +} + +/// Mock Oracle implementation +/// +/// Converts raw USD price to normalized format: +/// `smallest_stablecoin_units per smallest_collateral_unit` +pub struct MockOracle; + +impl MockOracle { + /// Convert raw USD price to normalized format for the vault pallet. + /// + /// Formula: normalized = raw_price × 10^stablecoin_dec / 10^collateral_dec + /// + /// Example: $4.21/DOT with DOT(10 dec) and pUSD(6 dec) + /// = 4.21 × 10^6 / 10^10 = 0.000421 + fn normalize_price(raw_price: FixedU128) -> FixedU128 { + let stablecoin_multiplier = 10u128.pow(STABLECOIN_DECIMALS); + let collateral_divisor = 10u128.pow(COLLATERAL_DECIMALS); + + // raw_price × stablecoin_multiplier / collateral_divisor + raw_price + .saturating_mul(FixedU128::saturating_from_integer(stablecoin_multiplier)) + .checked_div(&FixedU128::saturating_from_integer(collateral_divisor)) + .unwrap_or(FixedU128::zero()) + } +} + +impl ProvidePrice for MockOracle { + type Moment = u64; + + fn get_price(_asset: &Location) -> Option<(FixedU128, Self::Moment)> { + // For testing, we return the same price regardless of asset + // In production, this would look up the price for the specific asset + MOCK_RAW_PRICE.with(|p| { + p.borrow().map(|raw_price| { + let normalized = Self::normalize_price(raw_price); + let timestamp = MOCK_PRICE_TIMESTAMP.with(|t| *t.borrow()); + (normalized, timestamp) + }) + }) + } +} + +/// Mock Auctions implementation for testing. +/// Collateral is always native DOT, held via the `Seized` hold reason. +pub struct MockAuctions; + +impl AuctionsHandler for MockAuctions { + fn start_auction( + _vault_owner: u64, + _collateral_amount: u128, + _debt: crate::DebtComponents, + _keeper: u64, + ) -> Result { + if FAIL_AUCTION_START.with(|v| *v.borrow()) { + return Err(DispatchError::Other("mock auction start failure")); + } + + let auction_id = MOCK_AUCTION_ID.with(|id| { + let mut id = id.borrow_mut(); + *id += 1; + *id + }); + Ok(auction_id) + } +} + +// Configure a mock runtime to test the pallet. +#[frame_support::runtime] +mod runtime { + #[runtime::runtime] + #[runtime::derive( + RuntimeCall, + RuntimeEvent, + RuntimeError, + RuntimeOrigin, + RuntimeFreezeReason, + RuntimeHoldReason, + RuntimeSlashReason, + RuntimeLockId, + RuntimeTask + )] + pub struct Test; + + #[runtime::pallet_index(0)] + pub type System = frame_system; + #[runtime::pallet_index(1)] + pub type Balances = pallet_balances; + #[runtime::pallet_index(2)] + pub type Assets = pallet_assets; + #[runtime::pallet_index(3)] + pub type Vaults = crate; +} + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type Block = frame_system::mocking::MockBlock; + type AccountData = pallet_balances::AccountData; +} + +#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)] +impl pallet_balances::Config for Test { + type Balance = u128; + type ExistentialDeposit = ConstU128<1>; + type AccountStore = System; + type RuntimeHoldReason = RuntimeHoldReason; +} + +#[derive_impl(pallet_assets::config_preludes::TestDefaultConfig)] +impl pallet_assets::Config for Test { + type Balance = u128; + type AssetId = u32; + type AssetIdParameter = u32; + type Currency = Balances; + type CreateOrigin = AsEnsureOriginWithArg>; + type ForceOrigin = EnsureRoot; +} + +// DOT unit for collateral configuration (10 decimals) +const DOT_UNIT: u128 = 10u128.pow(COLLATERAL_DECIMALS); + +// pUSD unit (6 decimals) +const PUSD_UNIT: u128 = 1_000_000; + +/// FeeHandler account for surplus auction DOT proceeds +pub const FEE_HANDLER: u64 = 101; + +/// Treasury account for surplus pUSD transfers +pub const TREASURY: u64 = 102; + +parameter_types! { + pub const StablecoinAssetId: u32 = STABLECOIN_ASSET_ID; + pub const InsuranceFundAccount: u64 = INSURANCE_FUND; + pub const FeeHandlerAccount: u64 = FEE_HANDLER; + pub const TreasuryAccount: u64 = TREASURY; + // DOT from AH perspective is at Location::here() + pub CollateralLocation: Location = Location::here(); +} + +type VaultsAsset = ItemOf; + +/// Handler for surplus pUSD credits - deposits to Treasury account. +pub struct SurplusPusdToTreasury; + +impl OnUnbalanced> for SurplusPusdToTreasury { + fn on_nonzero_unbalanced(credit: FungibleCredit) { + let _ = VaultsAsset::resolve(&TREASURY, credit); + } +} + +/// Account ID used to represent Emergency privilege in tests. +/// When this account signs a transaction, it gets Emergency privilege level. +pub const EMERGENCY_ADMIN: u64 = 99; + +/// EnsureOrigin implementation for tests that supports both privilege levels: +/// - Root origin → VaultsManagerLevel::Full +/// - Signed by EMERGENCY_ADMIN → VaultsManagerLevel::Emergency +pub struct EnsureVaultsManagerMock; +impl EnsureOrigin for EnsureVaultsManagerMock { + type Success = crate::VaultsManagerLevel; + + fn try_origin(o: RuntimeOrigin) -> Result { + match o.clone().into() { + Ok(RawOrigin::Root) => Ok(crate::VaultsManagerLevel::Full), + Ok(RawOrigin::Signed(who)) if who == EMERGENCY_ADMIN => { + Ok(crate::VaultsManagerLevel::Emergency) + }, + _ => Err(o), + } + } + + #[cfg(feature = "runtime-benchmarks")] + fn try_successful_origin() -> Result { + Ok(RuntimeOrigin::root()) + } +} + +/// BenchmarkHelper implementation for tests. +#[cfg(feature = "runtime-benchmarks")] +pub struct MockBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl crate::BenchmarkHelper for MockBenchmarkHelper { + fn create_stablecoin_asset() { + // Asset may already exist from genesis, ignore error + let _ = Assets::create(RawOrigin::Signed(ALICE).into(), StablecoinAssetId::get(), ALICE, 1); + } + + fn advance_time(millis: u64) { + let current = MockTimestamp::get(); + MockTimestamp::set_timestamp(current + millis); + // Keep oracle price fresh + set_mock_price_timestamp(current + millis); + } + + fn set_price(price: FixedU128) { + // Convert from normalized format back to raw price for set_mock_price + // The set_mock_price function expects raw USD price (e.g., 4.21 for $4.21/DOT) + // But BenchmarkHelper::set_price receives normalized format + // We store it directly since MockOracle will normalize it anyway + MOCK_RAW_PRICE.with(|p| { + // Convert normalized price back to raw USD price + // normalized = raw × 10^6 / 10^10 = raw × 10^-4 + // raw = normalized × 10^4 = normalized × 10000 + let raw_price = price.saturating_mul(FixedU128::saturating_from_integer(10_000u128)); + *p.borrow_mut() = Some(raw_price); + }); + MOCK_PRICE_TIMESTAMP.with(|t| { + *t.borrow_mut() = MockTimestamp::get(); + }); + } +} + +impl crate::Config for Test { + type WeightInfo = (); + type Collateral = Balances; + type RuntimeHoldReason = RuntimeHoldReason; + type StableAsset = VaultsAsset; + type InsuranceFund = InsuranceFundAccount; + type FeeHandler = ResolveTo; + type SurplusHandler = SurplusPusdToTreasury; + type TimeProvider = MockTimestamp; + type MaxOnIdleItems = MockMaxOnIdleItems; + type Oracle = MockOracle; + type CollateralLocation = CollateralLocation; + type AuctionsHandler = MockAuctions; + type ManagerOrigin = EnsureVaultsManagerMock; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = MockBenchmarkHelper; +} + +/// Build genesis storage with default configuration +pub fn new_test_ext() -> TestState { + let mut storage = GenesisConfig::::default().build_storage().unwrap(); + + // Configure initial balances + // Note: All accounts must have at least the existential deposit (1) + pallet_balances::GenesisConfig:: { + balances: vec![ + (ALICE, INITIAL_BALANCE), + (BOB, INITIAL_BALANCE), + (CHARLIE, INITIAL_BALANCE), + (INSURANCE_FUND, 1), // Minimum existential deposit for insurance fund + ], + ..Default::default() + } + .assimilate_storage(&mut storage) + .unwrap(); + + // Configure assets pallet + pallet_assets::GenesisConfig:: { + assets: vec![ + // (asset_id, owner, is_sufficient, min_balance) + (STABLECOIN_ASSET_ID, ALICE, true, 1), + ], + metadata: vec![ + // (asset_id, name, symbol, decimals) + (STABLECOIN_ASSET_ID, b"pUSD Stablecoin".to_vec(), b"pUSD".to_vec(), 6), + ], + ..Default::default() + } + .assimilate_storage(&mut storage) + .unwrap(); + + // Configure vaults pallet with parameters from DESIGN.md Section 7 + crate::GenesisConfig:: { + // MinimumCollateralizationRatio: 180% + minimum_collateralization_ratio: FixedU128::from_rational(180, 100), + // InitialCollateralizationRatio: 200% + initial_collateralization_ratio: FixedU128::from_rational(200, 100), + // StabilityFee: 4% annual + stability_fee: Permill::from_percent(4), + // LiquidationPenalty: 13% + liquidation_penalty: Permill::from_percent(13), + // MaximumIssuance: 20 million pUSD + maximum_issuance: 20_000_000 * PUSD_UNIT, + // MaxLiquidationAmount: 20 million pUSD + max_liquidation_amount: 20_000_000 * PUSD_UNIT, + // MaxPositionAmount: 10 million pUSD + max_position_amount: 10_000_000 * PUSD_UNIT, + // MinimumDeposit: 100 DOT + minimum_deposit: 100 * DOT_UNIT, + // MinimumMint: 5 pUSD + minimum_mint: 5 * PUSD_UNIT, + // StaleVaultThreshold: 4 hours (14,400,000 ms) + stale_vault_threshold: 14_400_000, + // OracleStalenessThreshold: 1 hour (3,600,000 ms) + oracle_staleness_threshold: 3_600_000, + } + .assimilate_storage(&mut storage) + .unwrap(); + + let mut ext: TestState = storage.into(); + + // Initialize runtime state + ext.execute_with(|| { + System::set_block_number(1); + // Initialize timestamp to a reasonable starting value (e.g., Monday, 1 December 2025 + // 09:00:00 GMT+01:00) + MockTimestamp::set_timestamp(1764576000000); + set_mock_auction_start_failure(false); + // Reset mock price to default: 1 DOT = 4.21 USD + set_mock_price(Some(FixedU128::from_rational(421, 100))); + }); + + ext +} + +/// Build test externalities and execute with try_state checks. +/// +/// Runs `do_try_state()` after the test closure to verify all invariants hold. +/// Use this instead of `new_test_ext().execute_with()` for all tests. +pub fn build_and_execute(test: impl FnOnce()) { + let mut ext = new_test_ext(); + ext.execute_with(|| { + test(); + crate::Pallet::::do_try_state().unwrap(); + }); +} + +/// Milliseconds per block (6 second block time). +pub const MILLIS_PER_BLOCK: u64 = 6000; + +pub fn run_to_block(n: u64) { + System::run_to_block_with::( + n, + frame_system::RunToBlockHooks::default().before_initialize(|_bn| { + // Advance timestamp proportionally (6000ms per block) + let current_timestamp = MockTimestamp::get(); + MockTimestamp::set_timestamp(current_timestamp + MILLIS_PER_BLOCK); + }), + ); +} + +/// Advance the current timestamp by the given duration (in milliseconds). +pub fn advance_timestamp(millis: u64) { + let current = MockTimestamp::get(); + MockTimestamp::set_timestamp(current + millis); +} + +/// Jump directly to a target block without processing intermediate blocks. +/// +/// Use this when you need to simulate time passing (e.g., for interest accrual) +/// but don't need intermediate block hooks to run. Faster than `run_to_block` +/// for large block advances. +/// +/// Note: This skips on_initialize/on_finalize hooks for intermediate blocks, +/// but does run `on_idle` for the Vaults pallet at the target block. +/// Also updates the mock oracle price timestamp to keep the price fresh. +pub fn jump_to_block(n: u64) { + let current_block = System::block_number(); + assert!(n > current_block, "Can only jump forward in blocks"); + + let blocks_to_advance = n - current_block; + let time_to_advance = blocks_to_advance * MILLIS_PER_BLOCK; + + // Directly set block number and timestamp + System::set_block_number(n); + let current_timestamp = MockTimestamp::get(); + let new_timestamp = current_timestamp + time_to_advance; + MockTimestamp::set_timestamp(new_timestamp); + + // Keep oracle price fresh by updating its timestamp + set_mock_price_timestamp(new_timestamp); + + // Run on_idle for the Vaults pallet to process stale vaults + crate::Pallet::::on_idle(n, Weight::MAX); +} diff --git a/substrate/frame/vaults/src/tests.rs b/substrate/frame/vaults/src/tests.rs new file mode 100644 index 0000000000000..e5a278e74051d --- /dev/null +++ b/substrate/frame/vaults/src/tests.rs @@ -0,0 +1,5243 @@ +// This file is part of Substrate. + +// Copyright (C) Amforc AG. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{ + mock::*, BadDebt, CollateralManager, CurrentLiquidationAmount, DebtComponents, Error, Event, + HoldReason, InitialCollateralizationRatio, LiquidationPenalty, MaxLiquidationAmount, + MaxPositionAmount, MaximumIssuance, MinimumCollateralizationRatio, MinimumDeposit, MinimumMint, + OnIdleCursor, OracleStalenessThreshold, Pallet as VaultsPallet, PaymentBreakdown, + PreviousStabilityFee, StabilityFee, StaleVaultThreshold, Vault, VaultStatus, + Vaults as VaultsStorage, +}; +use frame_support::{ + assert_err, assert_noop, assert_ok, + traits::{ + fungible::{InspectHold, Mutate as FungibleMutate}, + fungibles::{Inspect as FungiblesInspect, Mutate as FungiblesMutate}, + Hooks, + }, +}; +use sp_runtime::{ + traits::{Bounded, CheckedDiv, One, Zero}, + FixedPointNumber, FixedU128, Permill, Saturating, TokenError, +}; + +// DOT has 10 decimals: 1 DOT = 10^10 smallest units +const DOT: u128 = 10_000_000_000; // 10^10 + +// pUSD has 6 decimals: 1 pUSD = 10^6 smallest units +const PUSD: u128 = 1_000_000; // 10^6 + +// Tolerance for interest calculations in tests. +// 6000 units = 0.006 pUSD - covers ~0.07% timestamp variance from jump_to_block. +const INTEREST_TOLERANCE: u128 = 6000; + +// Helper to create FixedU128 ratios (e.g., 150% = 1.5) +fn ratio(percent: u32) -> FixedU128 { + FixedU128::from_rational(percent as u128, 100) +} + +// Helper to check if actual is within tolerance of expected +fn assert_approx_eq(actual: u128, expected: u128, tolerance: u128, context: &str) { + assert!( + actual >= expected.saturating_sub(tolerance) && + actual <= expected.saturating_add(tolerance), + "{}: expected ~{}, got {} (tolerance: {}) (raw: {} vs {})", + context, + expected / PUSD, + actual / PUSD, + tolerance, + actual, + expected + ); +} + +mod create_vault { + use super::*; + + /// **Test: Opening a new Vault** + /// + /// Verifies that a user can open a new vault by depositing collateral (DOT). + /// - The collateral is locked in the system + /// - Initial debt is zero (no stablecoin borrowed yet) + /// - No interest has accrued yet + #[test] + fn works_with_initial_deposit() { + build_and_execute(|| { + let deposit = 100 * DOT; + + // Vault::default() produces a healthy, zero-debt vault + let default_vault = Vault::::default(); + assert_eq!(default_vault.status, VaultStatus::Healthy); + assert!(default_vault.principal.is_zero()); + assert!(default_vault.accrued_interest.is_zero()); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Check vault exists + let vault = VaultsStorage::::get(ALICE).expect("vault should exist"); + assert_eq!(vault.principal, 0); + assert_eq!(vault.accrued_interest, 0); + + // Check collateral is held + assert_eq!(vault.get_held_collateral(&ALICE), deposit); + + // Check events + System::assert_has_event(Event::::VaultCreated { owner: ALICE }.into()); + System::assert_has_event( + Event::::CollateralDeposited { owner: ALICE, amount: deposit }.into(), + ); + }); + } + + /// **Test: One vault per user policy** + /// + /// Ensures each user can only have ONE active vault at a time. + /// This simplifies risk management and prevents users from fragmenting + /// their collateral across multiple positions. + #[test] + fn fails_if_vault_already_exists() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + assert_noop!( + Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit), + Error::::VaultAlreadyExists + ); + }); + } + + /// **Test: Cannot deposit more collateral than owned** + /// + /// Prevents users from opening vaults with funds they don't have. + /// This is a basic solvency check. + #[test] + fn fails_with_insufficient_balance() { + build_and_execute(|| { + // Try to deposit more than ALICE has + let excessive_deposit = INITIAL_BALANCE + 1; + + // The hold mechanism returns TokenError::FundsUnavailable when balance is insufficient + assert_noop!( + Vaults::create_vault(RuntimeOrigin::signed(ALICE), excessive_deposit), + TokenError::FundsUnavailable + ); + }); + } +} + +mod deposit_collateral { + use super::*; + + /// **Test: Adding more collateral to an existing vault** + /// + /// Users can deposit additional collateral to improve their collateralization ratio. + #[test] + fn works() { + build_and_execute(|| { + let initial = 100 * DOT; + let additional = 50 * DOT; + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), initial)); + assert_ok!(Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), additional)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.get_held_collateral(&ALICE), initial + additional); + + System::assert_has_event( + Event::::CollateralDeposited { owner: ALICE, amount: additional }.into(), + ); + }); + } + + /// **Test: Cannot deposit to a non-existent vault** + /// + /// Users must first create a vault before they can deposit collateral. + #[test] + fn fails_if_vault_not_found() { + build_and_execute(|| { + assert_noop!( + Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), 100 * DOT), + Error::::VaultNotFound + ); + }); + } +} + +mod withdraw_collateral { + use super::*; + + /// **Test: Withdrawing collateral from a debt-free vault** + /// + /// When a vault has no outstanding debt, the user can freely withdraw + /// any amount of their collateral (as long as remaining >= `MinimumDeposit`). + #[test] + fn works_without_debt() { + build_and_execute(|| { + let initial = 200 * DOT; + let withdraw = 50 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), initial)); + assert_ok!(Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), withdraw)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.get_held_collateral(&ALICE), initial - withdraw); + + System::assert_has_event( + Event::::CollateralWithdrawn { owner: ALICE, amount: withdraw }.into(), + ); + }); + } + + /// **Test: Cannot withdraw more collateral than deposited** + /// + /// Basic validation that prevents withdrawing non-existent funds. + #[test] + fn fails_if_insufficient_collateral() { + build_and_execute(|| { + let initial = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), initial)); + + assert_noop!( + Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), initial + 1), + Error::::InsufficientCollateral + ); + }); + } + + /// **Test: Collateralization ratio protection on withdrawal** + /// + /// When a vault has debt, withdrawals are restricted to maintain the + /// initial collateralization ratio (200%). This prevents users from + /// removing collateral and leaving an undercollateralized position. + /// + /// Example scenario with 300 DOT: + /// - 300 DOT collateral at $4.21 = $1263 value + /// - 300 pUSD debt (ratio = 421%) + /// - To breach 200% ratio, need value < $600, i.e., < 142.5 DOT + /// - Withdrawing 160 DOT leaves 140 DOT = $589 value, ratio = 196% < 200% + #[test] + fn fails_if_would_breach_initial_ratio_with_debt() { + build_and_execute(|| { + // Oracle: 1 DOT = 4.21 USD + // Deposit 300 DOT = 1263 USD collateral value + let initial = 300 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), initial)); + + // Mint 300 pUSD debt (valid at 200% ICR since $1263 > $600) + // Current ratio = 1263/300 = 421% + let mint_amount = 300 * PUSD; + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // With 300 pUSD debt, need 300 * 2.0 = 600 USD = ~142.5 DOT minimum collateral + // Try to withdraw 160 DOT (leaves 140 DOT = $589 value, ratio = 196% < 200%) + // Note: 140 DOT > 100 DOT MinimumDeposit, so we hit ratio check first + assert_noop!( + Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 160 * DOT), + Error::::UnsafeCollateralizationRatio + ); + + // Try to withdraw 158 DOT (leaves 142 DOT = $598 value, ratio = 199% < 200%) + assert_noop!( + Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 158 * DOT), + Error::::UnsafeCollateralizationRatio + ); + + // Withdrawing 155 DOT should work (leaves 145 DOT = $610.5 value, ratio = 203% > 200%) + // And 145 DOT > 100 DOT MinimumDeposit + assert_ok!(Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 155 * DOT)); + }); + } + + /// **Test: Cannot create dust vaults via withdrawal** + /// + /// Prevents users from withdrawing collateral such that the remaining + /// amount is below `MinimumDeposit` (100 DOT). This prevents storage bloat + /// from tiny "dust" vaults. + /// + /// Withdrawing ALL collateral is allowed (when debt == 0) and auto-closes the vault. + #[test] + fn fails_if_would_create_dust_vault() { + build_and_execute(|| { + let initial = 200 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), initial)); + + // Try to withdraw 150 DOT, leaving only 50 DOT (below `MinimumDeposit` of 100 DOT) + assert_noop!( + Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 150 * DOT), + Error::::BelowMinimumDeposit + ); + + // Withdrawing 100 DOT should work (leaves 100 DOT = `MinimumDeposit`) + assert_ok!(Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.get_held_collateral(&ALICE), 100 * DOT); + }); + } + + /// **Test: Cannot withdraw all collateral when vault has debt** + /// + /// Even if the collateralization ratio would be "infinite" (0 collateral / debt), + /// we don't allow withdrawing all collateral when there's outstanding debt. + /// Users must repay debt first, then close the vault. + #[test] + fn fails_if_withdrawing_all_with_debt() { + build_and_execute(|| { + let initial = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), initial)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + // Try to withdraw all collateral while having debt + assert_noop!( + Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), initial), + Error::::VaultHasDebt // Cannot leave vault with 0 collateral and debt + ); + }); + } + + /// **Test: Withdrawing all collateral closes the vault immediately** + /// + /// When a user withdraws all their collateral (only possible with zero debt), + /// the vault is immediately removed from storage. + /// Both `CollateralWithdrawn` and `VaultClosed` events are emitted. + #[test] + fn auto_closes_vault_when_withdrawing_all() { + build_and_execute(|| { + let initial = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), initial)); + assert!(VaultsStorage::::get(ALICE).is_some()); + + // Withdraw all collateral (no debt, so this is allowed) + assert_ok!(Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), initial)); + + // Vault should be immediately removed. + assert!(VaultsStorage::::get(ALICE).is_none()); + + // Both events should be emitted + System::assert_has_event( + Event::::CollateralWithdrawn { owner: ALICE, amount: initial }.into(), + ); + System::assert_has_event(Event::::VaultClosed { owner: ALICE }.into()); + + // User should be able to create a new vault immediately + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), initial)); + }); + } +} + +mod mint { + use super::*; + + /// **Test: Borrowing stablecoin (pUSD) against collateral** + /// + /// This is the core vault operation - users lock collateral and mint stablecoin. + /// The amount they can borrow depends on: + /// - The value of their collateral (DOT price × amount) + /// - The initial collateralization ratio (200%) + /// + /// Example: 100 DOT at $4.21 = $421 → max borrow = $421 / 2.0 = 210.5 pUSD + #[test] + fn works() { + build_and_execute(|| { + // Oracle: 1 DOT = 4.21 USD + // Deposit 100 DOT = 421 USD collateral value + let deposit = 100 * DOT; + // At 200% initial ratio, max mint = 421 / 2.0 = 210.5 pUSD + let mint_amount = 200 * PUSD; // Safe amount below max + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, mint_amount); + + // Check pUSD was minted to ALICE + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, ALICE), mint_amount); + + System::assert_has_event( + Event::::Minted { owner: ALICE, amount: mint_amount }.into(), + ); + }); + } + + /// **Test: Initial collateralization ratio enforcement (200%)** + /// + /// When minting NEW debt, the vault must maintain 200% collateralization + /// (not just the 180% minimum). This creates a safety buffer so users + /// don't get liquidated immediately after borrowing. + #[test] + fn fails_if_exceeds_initial_ratio() { + build_and_execute(|| { + // Oracle: 1 DOT = 4.21 USD + // 100 DOT = 421 USD, max at 200% = 210.5 pUSD + let deposit = 100 * DOT; + let excessive_mint = 220 * PUSD; // Would result in ~191% ratio < 200% + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), excessive_mint), + Error::::UnsafeCollateralizationRatio + ); + }); + } + + /// **Test: System-wide debt ceiling enforcement** + /// + /// The protocol has a maximum total debt limit to manage systemic risk. + /// Even if a user has sufficient collateral, they cannot mint if it would + /// push total system debt above the ceiling. + #[test] + fn fails_if_exceeds_max_debt() { + build_and_execute(|| { + // Set a low max debt + MaximumIssuance::::put(100 * PUSD); + + // Deposit enough collateral for large mint (but not all balance due to existential + // deposit) + let deposit = 500 * DOT; + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Try to mint more than max debt + // With 500 DOT at price 4.21 = 2105 USD value, we can mint up to ~1052 pUSD at 200% ICR + // But max debt is only 100 pUSD + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD), + Error::::ExceedsMaxDebt + ); + }); + } + + /// **Test: Cannot mint without an existing vault** + /// + /// Users must first create a vault with collateral before borrowing. + #[test] + fn fails_if_vault_not_found() { + build_and_execute(|| { + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD), + Error::::VaultNotFound + ); + }); + } + + /// **Test: Oracle price required for minting** + /// + /// The system needs a valid price feed to calculate collateralization ratios. + /// If the oracle is unavailable, minting is blocked to prevent under-collateralized loans. + #[test] + fn fails_if_price_not_available() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Remove oracle price + set_mock_price(None); + + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD), + Error::::PriceNotAvailable + ); + }); + } +} + +mod repay { + use super::*; + + /// **Test: Partial debt repayment** + /// + /// Users can repay any portion of their debt at any time. + /// The repaid pUSD is burned (removed from circulation), reducing + /// the vault's debt and improving its collateralization ratio. + /// When interest has accrued, repayment covers interest first, then principal. + #[test] + fn works_partial_repayment() { + build_and_execute(|| { + // Oracle: 1 DOT = 4.21 USD + // 100 DOT = 421 USD, max mint at 200% = 210.5 pUSD + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + let first_repay = 80 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), first_repay)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, mint_amount - first_repay); + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, ALICE), mint_amount - first_repay); + + System::assert_has_event( + Event::::Repaid { owner: ALICE, principal: first_repay, interest: 0 }.into(), + ); + + // Advance time to accrue interest + jump_to_block(5_256_000); + assert_ok!(Vaults::poke(RuntimeOrigin::signed(BOB), ALICE)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let interest = vault.accrued_interest; + let principal_before = vault.principal; + assert!(interest > 0, "Interest should have accrued"); + + // Repay amount that covers all interest + some principal + let principal_to_repay = 50 * PUSD; + let second_repay = interest + principal_to_repay; + + // Alice needs extra pUSD to cover interest + assert_ok!(Assets::mint( + RuntimeOrigin::signed(ALICE), + STABLECOIN_ASSET_ID, + ALICE, + interest + )); + + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), second_repay)); + + let vault_after = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault_after.accrued_interest, 0, "Interest should be fully paid"); + assert_eq!( + vault_after.principal, + principal_before - principal_to_repay, + "Principal should be reduced by the remainder" + ); + + System::assert_has_event( + Event::::Repaid { owner: ALICE, principal: principal_to_repay, interest } + .into(), + ); + }); + } + /// **Test: Overpayment is capped to actual debt** + /// + /// If a user tries to repay more than they owe (but has sufficient balance), + /// only the actual debt amount is burned. The excess pUSD is not consumed and a + /// `ReturnedExcess` event is emitted. + #[test] + fn caps_repayment_to_debt() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + // ALICE creates vault and mints 200 pUSD + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // BOB creates vault with enough collateral to mint 300 pUSD + // At $4.21/DOT and 200% ICR: need 300 * 2 / 4.21 = ~143 DOT minimum + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(BOB), 150 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(BOB), 300 * PUSD)); + assert_ok!(Assets::transfer( + RuntimeOrigin::signed(BOB), + STABLECOIN_ASSET_ID, + ALICE, + 300 * PUSD + )); + + // ALICE now has 500 pUSD (200 minted + 300 from BOB) but only 200 pUSD debt + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, ALICE), 500 * PUSD); + + // Try to repay 500 pUSD - should only repay actual debt (200 pUSD) + // No interest has accrued (same block), so excess = 500 - 200 = 300 pUSD + let repay_amount = 500 * PUSD; + let expected_excess = repay_amount - mint_amount; // 300 pUSD + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), repay_amount)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 0); + + // Only 200 pUSD should be burned (the debt), leaving 300 pUSD + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, ALICE), 300 * PUSD); + + // ReturnedExcess event should be emitted for the unused amount + System::assert_has_event( + Event::::ReturnedExcess { owner: ALICE, amount: expected_excess }.into(), + ); + }); + } +} + +mod liquidate { + use super::*; + + /// **Test: Liquidation of undercollateralized vault** + /// + /// When the collateral value drops below 180% of debt (due to price decline), + /// anyone can trigger liquidation. This protects the protocol from bad debt. + /// + /// Example scenario: + /// - Initial: 100 DOT at $4.21 = $421, debt = 200 pUSD → ratio = 210% + /// - After price drop to $3: 100 DOT = $300, debt = 200 pUSD → ratio = 150% + /// - Since 150% < 180% minimum, liquidation is allowed + #[test] + fn works_when_undercollateralized() { + build_and_execute(|| { + // Oracle: 1 DOT = 4.21 USD + // 100 DOT = 421 USD, max mint at 200% = 210.5 pUSD + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Mint at safe ratio: 200 pUSD gives ratio = 421/200 = 210% + let mint_amount = 200 * PUSD; + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Drop price to make vault undercollateralized + // Current: 100 DOT * 4.21 USD = 421 USD, debt = 200 USD, ratio = 210% + // New price: 100 DOT * 3.0 USD = 300 USD, debt = 200 USD, ratio = 150% < 180% + set_mock_price(Some(FixedU128::from_u32(3))); + + // BOB liquidates ALICE's vault + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Vault should still exist but with InLiquidation status + let vault = + VaultsStorage::::get(ALICE).expect("Vault should exist during liquidation"); + assert_eq!( + vault.status, + VaultStatus::InLiquidation, + "Vault should be in InLiquidation status" + ); + + // Collateral should now be held with Seized reason (for auction) + // Check that ALICE has funds on hold with Seized reason + let seized_balance = Balances::balance_on_hold(&HoldReason::Seized.into(), &ALICE); + assert_eq!(seized_balance, deposit, "All collateral should be seized"); + + // Check InLiquidation event + let events = System::events(); + let liquidated_event = events + .iter() + .find(|e| matches!(e.event, RuntimeEvent::Vaults(Event::InLiquidation { .. }))); + assert!(liquidated_event.is_some(), "Should emit InLiquidation event"); + }); + } + + #[test] + fn failed_auction_start_rolls_back_hold_changes() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + set_mock_auction_start_failure(true); + + let result = Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE); + assert!(result.is_err(), "liquidation should fail when auction start fails"); + + let vault = + VaultsStorage::::get(ALICE).expect("vault should remain after failure"); + assert_eq!(vault.status, VaultStatus::Healthy); + assert_eq!(CurrentLiquidationAmount::::get(), 0); + assert_eq!( + Balances::balance_on_hold(&HoldReason::VaultDeposit.into(), &ALICE), + deposit + ); + assert_eq!(Balances::balance_on_hold(&HoldReason::Seized.into(), &ALICE), 0); + }); + } + + /// **Test: Cannot liquidate a healthy vault** + /// + /// Vaults with collateralization ratio ≥ 180% are considered "safe" and + /// cannot be liquidated. This protects vault owners from unfair liquidation. + #[test] + fn fails_if_vault_is_safe() { + build_and_execute(|| { + // Oracle: 1 DOT = 4.21 USD + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Mint conservatively: 200 pUSD gives ratio = 421/200 = 210% > 180% + let mint_amount = 200 * PUSD; + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultIsSafe + ); + }); + } + + /// **Test: Liquidating a debt-free vault must not panic** + /// + /// A vault with zero debt is always safe and should return VaultIsSafe. + #[test] + fn zero_debt_vault_is_safe() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultIsSafe + ); + }); + } + + /// **Test: Cannot liquidate non-existent vault** + /// + /// Basic validation that liquidation requires an actual vault to exist. + #[test] + fn fails_if_vault_not_found() { + build_and_execute(|| { + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultNotFound + ); + }); + } +} + +mod close_vault { + use super::*; + + /// **Test: Close vault and withdraw all collateral from a debt-free vault** + /// + /// When a vault has no outstanding debt, the owner can close it and withdraw all + /// collateral. The vault is immediately removed from storage. + /// This is the normal "exit" flow for users who no longer need their Vault. + #[test] + fn works_with_no_debt() { + build_and_execute(|| { + let deposit = 100 * DOT; + + let alice_before = Balances::free_balance(ALICE); + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + assert_ok!(Vaults::close_vault(RuntimeOrigin::signed(ALICE))); + + // Vault should be removed immediately + assert!(VaultsStorage::::get(ALICE).is_none()); + + // Collateral should be returned - ALICE's balance restored to original + // (alice_before - deposit during hold + deposit released = alice_before) + assert_eq!(Balances::free_balance(ALICE), alice_before); + + System::assert_has_event( + Event::::CollateralWithdrawn { owner: ALICE, amount: deposit }.into(), + ); + System::assert_has_event(Event::::VaultClosed { owner: ALICE }.into()); + }); + } + + /// **Test: Cannot close vault with outstanding debt** + /// + /// Vaults with unpaid debt cannot be closed. Users must first + /// repay all borrowed pUSD before they can close the vault. + /// This ensures the protocol's stablecoin remains fully backed. + #[test] + fn fails_with_outstanding_debt() { + build_and_execute(|| { + // Oracle: 1 DOT = 4.21 USD + // 100 DOT = 421 USD, max mint at 200% = 210.5 pUSD + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + assert_noop!( + Vaults::close_vault(RuntimeOrigin::signed(ALICE)), + Error::::VaultHasDebt + ); + }); + } + + /// **Test: `close_vault` requires all debt (principal + interest) to be repaid** + /// + /// Per spec, Debt == 0 means both principal AND accrued_interest must be zero. + /// If only principal is zero but interest remains, closing should fail. + #[test] + fn close_vault_fails_with_accrued_interest() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Accrue interest over time. + jump_to_block(5_256_000); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let interest = vault.accrued_interest; + assert!(interest > 0, "Interest should be accrued"); + + // Directly set principal=0 while keeping interest>0 to test defensive check. + // This state cannot occur through normal extrinsics (repay pays interest first). + VaultsStorage::::mutate(ALICE, |maybe_vault| { + let vault = maybe_vault.as_mut().expect("vault should exist"); + vault.principal = 0; + }); + + // Cannot close - still has accrued interest + assert_noop!( + Vaults::close_vault(RuntimeOrigin::signed(ALICE)), + Error::::VaultHasDebt + ); + }); + } + + /// **Test: `close_vault` succeeds after repaying all debt including interest** + /// + /// User must repay both principal and accrued interest before closing. + /// With the mint-on-accrual model, interest is minted to InsuranceFund when + /// fees accrue, and burned from the user when repaid. + #[test] + fn close_vault_succeeds_after_full_debt_repayment() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Check IF balance before interest accrues + let insurance_before_accrual = Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND); + + // Accrue interest over time. + jump_to_block(5_256_000); + assert_ok!(Vaults::poke(RuntimeOrigin::signed(BOB), ALICE)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let total_debt = vault.principal + vault.accrued_interest; + assert!(vault.accrued_interest > 0, "Interest should be accrued"); + + // Verify IF received interest during accrual (mint-on-accrual model) + let insurance_after_accrual = Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND); + assert_eq!( + insurance_after_accrual, + insurance_before_accrual + vault.accrued_interest, + "`InsuranceFund` should receive interest on accrual" + ); + + // Alice only has the minted pUSD, need extra to cover interest + // Mint extra pUSD directly to Alice for interest payment + assert_ok!(Assets::mint( + RuntimeOrigin::signed(ALICE), + STABLECOIN_ASSET_ID, + ALICE, + vault.accrued_interest + )); + + // Repay all debt (principal + interest) + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), total_debt)); + + // IF balance should not change on repay (interest was minted on accrual) + let insurance_after_repay = Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND); + assert_eq!( + insurance_after_repay, insurance_after_accrual, + "`InsuranceFund` balance unchanged on repay (already received on accrual)" + ); + + // Now can close the vault + assert_ok!(Vaults::close_vault(RuntimeOrigin::signed(ALICE))); + assert!(VaultsStorage::::get(ALICE).is_none()); + }); + } +} + +mod parameter_setters { + use super::*; + + /// **Test: `ManagerOrigin` can update minimum collateralization ratio** + /// + /// The `ManagerOrigin` with `Full` privilege can adjust the minimum ratio (default 180%) + /// that determines when vaults become liquidatable. Lowering this makes the + /// protocol more capital-efficient but riskier; raising it makes it safer + /// but requires more collateral per borrowed pUSD. + #[test] + fn set_minimum_collateralization_ratio_works() { + build_and_execute(|| { + let old_ratio = ratio(180); // Genesis default + let new_ratio = ratio(190); + + assert_ok!(Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::root(), + new_ratio + )); + + assert_eq!(MinimumCollateralizationRatio::::get(), Some(new_ratio)); + System::assert_has_event( + Event::::MinimumCollateralizationRatioUpdated { + old_value: old_ratio, + new_value: new_ratio, + } + .into(), + ); + }); + } + + /// **Test: Only `ManagerOrigin` can change minimum ratio** + /// + /// Regular users cannot change protocol parameters. This ensures + /// critical risk parameters are controlled by `ManagerOrigin` only. + #[test] + fn set_minimum_collateralization_ratio_fails_for_non_manager() { + build_and_execute(|| { + assert_noop!( + Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::signed(ALICE), + ratio(140) + ), + frame_support::error::BadOrigin + ); + }); + } + + /// **Test: Governance can update initial collateralization ratio** + /// + /// The initial ratio (default 200%) determines the maximum amount + /// users can borrow when minting. It's higher than the minimum ratio + /// to provide a safety buffer against immediate liquidation. + #[test] + fn set_initial_collateralization_ratio_works() { + build_and_execute(|| { + let old_ratio = ratio(200); // Genesis default + let new_ratio = ratio(210); + + assert_ok!(Vaults::set_initial_collateralization_ratio( + RuntimeOrigin::root(), + new_ratio + )); + + assert_eq!(InitialCollateralizationRatio::::get(), Some(new_ratio)); + System::assert_has_event( + Event::::InitialCollateralizationRatioUpdated { + old_value: old_ratio, + new_value: new_ratio, + } + .into(), + ); + }); + } + + /// **Test: Governance can update stability fee** + /// + /// The stability fee (default 4% annually) is the interest rate charged + /// on borrowed pUSD. It's denominated in pUSD but paid from collateral (DOT). + /// Revenue goes to the protocol treasury. + #[test] + fn set_stability_fee_works() { + build_and_execute(|| { + let old_fee = Permill::from_percent(4); // Genesis default + let new_fee = Permill::from_percent(10); + + assert_ok!(Vaults::set_stability_fee(RuntimeOrigin::root(), new_fee)); + + assert_eq!(StabilityFee::::get(), Some(new_fee)); + System::assert_has_event( + Event::::StabilityFeeUpdated { old_value: old_fee, new_value: new_fee } + .into(), + ); + }); + } + + /// **Test: Governance can update liquidation penalty** + /// + /// The liquidation penalty (default 13%) is charged when a vault is + /// liquidated due to undercollateralization. This fee goes to the keeper + /// who initiates the liquidation, incentivizing timely vault liquidations. + #[test] + fn set_liquidation_penalty_works() { + build_and_execute(|| { + let old_penalty = Permill::from_percent(13); // Genesis default + let new_penalty = Permill::from_percent(15); + + assert_ok!(Vaults::set_liquidation_penalty(RuntimeOrigin::root(), new_penalty)); + + assert_eq!(LiquidationPenalty::::get(), Some(new_penalty)); + System::assert_has_event( + Event::::LiquidationPenaltyUpdated { + old_value: old_penalty, + new_value: new_penalty, + } + .into(), + ); + }); + } +} + +mod authorization_levels { + use super::*; + + /// **Test: `Emergency` privilege cannot set minimum collateralization ratio** + #[test] + fn emergency_privilege_cannot_set_minimum_collateralization_ratio() { + build_and_execute(|| { + assert_noop!( + Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::signed(EMERGENCY_ADMIN), + ratio(140) + ), + Error::::InsufficientPrivilege + ); + }); + } + + /// **Test: `Emergency` privilege cannot set initial collateralization ratio** + #[test] + fn emergency_privilege_cannot_set_initial_collateralization_ratio() { + build_and_execute(|| { + assert_noop!( + Vaults::set_initial_collateralization_ratio( + RuntimeOrigin::signed(EMERGENCY_ADMIN), + ratio(160) + ), + Error::::InsufficientPrivilege + ); + }); + } + + /// **Test: `Emergency` privilege cannot set stability fee** + #[test] + fn emergency_privilege_cannot_set_stability_fee() { + build_and_execute(|| { + assert_noop!( + Vaults::set_stability_fee( + RuntimeOrigin::signed(EMERGENCY_ADMIN), + Permill::from_percent(10) + ), + Error::::InsufficientPrivilege + ); + }); + } + + /// **Test: `Emergency` privilege cannot set liquidation penalty** + #[test] + fn emergency_privilege_cannot_set_liquidation_penalty() { + build_and_execute(|| { + assert_noop!( + Vaults::set_liquidation_penalty( + RuntimeOrigin::signed(EMERGENCY_ADMIN), + Permill::from_percent(15) + ), + Error::::InsufficientPrivilege + ); + }); + } + + /// **Test: `Emergency` privilege cannot set max liquidation amount** + #[test] + fn emergency_privilege_cannot_set_max_liquidation_amount() { + build_and_execute(|| { + assert_noop!( + Vaults::set_max_liquidation_amount( + RuntimeOrigin::signed(EMERGENCY_ADMIN), + 500_000 * PUSD + ), + Error::::InsufficientPrivilege + ); + }); + } + + /// **Test: `Full` privilege (`ManagerOrigin`) can raise maximum debt** + #[test] + fn full_privilege_can_raise_maximum_debt() { + build_and_execute(|| { + let current = MaximumIssuance::::get().expect("set in genesis; qed"); + let new_debt = current + 1_000_000 * PUSD; + assert_ok!(Vaults::set_max_issuance(RuntimeOrigin::root(), new_debt)); + assert_eq!(MaximumIssuance::::get(), Some(new_debt)); + }); + } + + /// **Test: `Full` privilege (`ManagerOrigin`) can lower maximum debt** + #[test] + fn full_privilege_can_lower_maximum_debt() { + build_and_execute(|| { + let current = MaximumIssuance::::get().expect("set in genesis; qed"); + let new_debt = current / 2; + assert_ok!(Vaults::set_max_issuance(RuntimeOrigin::root(), new_debt)); + assert_eq!(MaximumIssuance::::get(), Some(new_debt)); + }); + } + + /// **Test: `Emergency` privilege can lower maximum debt** + /// + /// This is the key emergency action - allowing fast-track lowering of the + /// debt ceiling in response to oracle attacks or other emergencies. + #[test] + fn emergency_privilege_can_lower_maximum_debt() { + build_and_execute(|| { + let current = MaximumIssuance::::get().expect("set in genesis; qed"); + let new_debt = current / 2; + assert_ok!(Vaults::set_max_issuance(RuntimeOrigin::signed(EMERGENCY_ADMIN), new_debt)); + assert_eq!(MaximumIssuance::::get(), Some(new_debt)); + }); + } + + /// **Test: `Emergency` privilege cannot raise maximum debt** + /// + /// `Emergency` actions are defensive only - they cannot increase risk exposure. + #[test] + fn emergency_privilege_cannot_raise_maximum_debt() { + build_and_execute(|| { + let current = MaximumIssuance::::get().expect("set in genesis; qed"); + let new_debt = current + 1_000_000 * PUSD; + assert_noop!( + Vaults::set_max_issuance(RuntimeOrigin::signed(EMERGENCY_ADMIN), new_debt), + Error::::CanOnlyLowerMaxDebt + ); + }); + } + + /// **Test: `Emergency` privilege can set maximum debt to zero** + /// + /// In an extreme emergency, the debt ceiling can be set to zero to + /// completely halt new minting. + #[test] + fn emergency_privilege_can_set_max_issuance_to_zero() { + build_and_execute(|| { + assert_ok!(Vaults::set_max_issuance(RuntimeOrigin::signed(EMERGENCY_ADMIN), 0)); + assert_eq!(MaximumIssuance::::get(), Some(0)); + }); + } + + /// **Test: Regular signed origin has no privilege** + /// + /// Neither `Full` nor `Emergency` - should fail with `BadOrigin`. + #[test] + fn regular_signed_origin_has_no_privilege() { + build_and_execute(|| { + // ALICE is a regular user, not a privileged origin + assert_noop!( + Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::signed(ALICE), + ratio(140) + ), + frame_support::error::BadOrigin + ); + + assert_noop!( + Vaults::set_max_issuance(RuntimeOrigin::signed(ALICE), 0), + frame_support::error::BadOrigin + ); + }); + } +} + +mod fee_accrual { + use super::*; + + /// **Test: Stability fee accrues over time** + /// + /// Interest on debt is calculated continuously based on time elapsed. + /// Accrued interest (in pUSD) is tracked in the vault's `accrued_interest` field. + /// + /// Example: 200 pUSD debt × 4% annual × 1 year = 8 pUSD interest + #[test] + fn interest_accrues_over_time() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Check initial state + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.accrued_interest, 0); + let initial_held = vault.get_held_collateral(&ALICE); + + // Advance 1 year worth of blocks (5,256,000 blocks at 6s each). + jump_to_block(5_256_000); + + // With 4% annual fee on 200 pUSD = 8 pUSD interest + let vault = VaultsStorage::::get(ALICE).unwrap(); + let expected_interest = 8 * PUSD; + assert_approx_eq( + vault.accrued_interest, + expected_interest, + INTEREST_TOLERANCE, + "Interest after 1 year", + ); + + // Collateral should NOT be reduced (interest is not collected yet) + let final_held = vault.get_held_collateral(&ALICE); + assert_eq!( + final_held, initial_held, + "Collateral should not be reduced until close_vault" + ); + + // `InterestAccrued` event should be emitted + let events = System::events(); + let has_interest = events + .iter() + .any(|e| matches!(e.event, RuntimeEvent::Vaults(Event::InterestAccrued { .. }))); + assert!(has_interest, "Should emit `InterestAccrued` event"); + }); + } + + /// **Test: Stability fee calculation example** + /// + /// This test validates the complete interest calculation using a concrete example: + /// + /// **Setup:** + /// - User deposits 10,000 DOT as collateral + /// - Borrows 10,000 pUSD (at $2/DOT, this is 200% collateralized) + /// - Stability fee: 4% annually + /// + /// **After 1 year:** + /// - Interest accrued: 10,000 pUSD × 4% = 400 pUSD + /// - Collateral remains unchanged at 10,000 DOT + /// - New collateralization ratio: (10,000 × $2) / (10,000 + 400) = 192% + #[test] + fn example_stability_fee_calculation() { + build_and_execute(|| { + // Set price to $2/DOT (2 pUSD per DOT) + set_mock_price(Some(FixedU128::from_u32(2))); + + // Setup: 10k DOT collateral, 10k pUSD debt + let collateral = 10_000 * DOT; + let debt = 10_000 * PUSD; + + // ALICE starts with INITIAL_BALANCE (1000 DOT) defined at genesis. + // She needs 10,000 DOT for collateral + 1 extra unit to remain after the hold. + // Additional needed: collateral + buffer - INITIAL_BALANCE = 9000 DOT + 1 unit. + let additional_needed = collateral + 1 - INITIAL_BALANCE; + let _ = Balances::mint_into(&ALICE, additional_needed); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), collateral)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), debt)); + + // Verify initial state + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!( + vault.get_held_collateral(&ALICE), + collateral, + "Initial collateral: 10k DOT" + ); + assert_eq!(vault.principal, debt, "Initial principal: 10k pUSD"); + + // Verify initial collateralization ratio is 200% + // CR = (collateral × price) / (debt + interest) = (10,000 × 2) / 10,000 = 200% + let initial_ratio = Vaults::get_collateralization_ratio(&vault, &ALICE).unwrap(); + assert_eq!(initial_ratio, ratio(200), "Initial ratio should be 200%"); + + // Advance exactly 1 year (5,256,000 blocks at 6s each). + jump_to_block(5_256_000); + + // Calculate expected values: + // Interest in pUSD = 4% * 10,000 pUSD = 400 pUSD + let expected_interest = 400 * PUSD; + + // Verify interest was accrued + // Use larger tolerance (0.5 pUSD) for this test due to ~0.07% timestamp variance + // over 1 year with jump_to_block (273k units variance on 400 pUSD expected) + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_approx_eq( + vault.accrued_interest, + expected_interest, + PUSD / 2, + "Interest after 1 year", + ); + + // Verify collateral is unchanged + assert_eq!( + vault.get_held_collateral(&ALICE), + collateral, + "Collateral should remain 10k DOT" + ); + + // Verify new collateralization ratio is ~192.31% + // Collateral value: 10,000 DOT × $2 = $20,000 + // Total debt: 10,000 pUSD principal + 400 pUSD interest = 10,400 pUSD + // CR = 20,000 / 10,400 = ≈ 1.9231 = 192.31% + let final_ratio = Vaults::get_collateralization_ratio(&vault, &ALICE).unwrap(); + let expected_ratio = FixedU128::from_rational(20000, 10400); + // Allow 0.01% tolerance for interest rounding + let tolerance = expected_ratio / FixedU128::from_u32(10_000); + assert!( + final_ratio >= expected_ratio.saturating_sub(tolerance) && + final_ratio <= expected_ratio.saturating_add(tolerance), + "Final ratio should be ~192.31%, got: {:?}, expected: {:?}", + final_ratio, + expected_ratio + ); + + // Verify debt unchanged + assert_eq!(vault.principal, debt, "Principal should remain 10k pUSD"); + }); + } + + /// **Test: Interest accrues even when debt ceiling is reached** + /// + /// This test verifies that interest (representing existing debt obligations) + /// can still be minted to the Insurance Fund even after the system debt ceiling + /// (`MaximumIssuance`) has been reached. This is intentional because: + /// - Interest represents obligations already incurred, not new borrowing + /// - Blocking interest would leave debt untracked on-chain + /// - The Insurance Fund must receive revenue for system solvency + /// + /// Principal mints are still blocked when the ceiling is reached. + #[test] + fn interest_accrues_even_at_debt_ceiling() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Set debt ceiling to current issuance (no room for new mints) + let current_issuance = Assets::total_issuance(StablecoinAssetId::get()); + assert_ok!(Vaults::set_max_issuance(RuntimeOrigin::root(), current_issuance)); + + // Verify principal minting is now blocked + assert_ok!(Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), 50 * DOT)); + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 10 * PUSD), + Error::::ExceedsMaxDebt + ); + + // Advance time to accrue interest + jump_to_block(5_256_000); // ~1 year + + // Check that interest was still minted despite ceiling + let vault = VaultsStorage::::get(ALICE).unwrap(); + let expected_interest = 8 * PUSD; // 4% of 200 pUSD + assert_approx_eq( + vault.accrued_interest, + expected_interest, + INTEREST_TOLERANCE, + "Interest should accrue despite debt ceiling", + ); + + // Insurance Fund should have received the interest + let fund_balance = Assets::balance(StablecoinAssetId::get(), &INSURANCE_FUND); + assert_approx_eq( + fund_balance, + expected_interest, + INTEREST_TOLERANCE, + "Insurance Fund should receive minted interest", + ); + + // Total issuance should now exceed the ceiling (interest was minted) + let new_issuance = Assets::total_issuance(StablecoinAssetId::get()); + assert!( + new_issuance > current_issuance, + "Total issuance should exceed ceiling due to interest" + ); + }); + } + + /// **Test: Fee change does not apply retroactively** + /// + /// When governance changes the stability fee, the new rate should only apply + /// from the moment of the change, not retroactively to the period before it. + /// + /// Timeline: + /// t=0: vault created, 200 pUSD debt at 4% fee + /// t=6 months: fee raised to 10% + /// t=12 months: vault poked + /// + /// Expected interest: + /// [0, 6mo] at 4%: 200 × 4% × 0.5 = 4 pUSD + /// [6mo, 12mo] at 10%: 200 × 10% × 0.5 = 10 pUSD + /// Total: 14 pUSD + #[test] + fn fee_change_does_not_apply_retroactively() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Advance 6 months (half year = 2,628,000 blocks at 6s) + let half_year_blocks = 2_628_000u64; + jump_to_block(half_year_blocks); + + // Governance raises stability fee from 4% to 10% + assert_ok!(Vaults::set_stability_fee(RuntimeOrigin::root(), Permill::from_percent(10))); + + // Verify PreviousStabilityFee was stored + let prev = PreviousStabilityFee::::get(); + assert!(prev.is_some(), "PreviousStabilityFee should be set"); + let (old_fee, changed_at) = prev.unwrap(); + assert_eq!(old_fee, Permill::from_percent(4)); + assert_eq!(changed_at, MockTimestamp::get()); + + // Advance another 6 months + jump_to_block(half_year_blocks * 2); + + // Expected: 4 pUSD (first half at 4%) + 10 pUSD (second half at 10%) = 14 pUSD + let vault = VaultsStorage::::get(ALICE).unwrap(); + let expected_interest = 14 * PUSD; + assert_approx_eq( + vault.accrued_interest, + expected_interest, + PUSD / 2, // 0.5 pUSD tolerance for timestamp variance + "Fee change should not apply retroactively", + ); + }); + } + + /// **Test: Fee decrease does not retroactively reduce owed interest** + /// + /// When governance lowers the stability fee, the old (higher) rate must still + /// apply to the period before the change. + /// + /// Timeline: + /// t=0: vault created, 200 pUSD debt at 4% fee + /// t=6 months: fee lowered to 1% + /// t=12 months: vault poked + /// + /// Expected interest: + /// [0, 6mo] at 4%: 200 × 4% × 0.5 = 4 pUSD + /// [6mo, 12mo] at 1%: 200 × 1% × 0.5 = 1 pUSD + /// Total: 5 pUSD + #[test] + fn fee_decrease_does_not_retroactively_reduce_interest() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Advance 6 months + let half_year_blocks = 2_628_000u64; + jump_to_block(half_year_blocks); + + // Governance lowers stability fee from 4% to 1% + assert_ok!(Vaults::set_stability_fee(RuntimeOrigin::root(), Permill::from_percent(1))); + + // Advance another 6 months + jump_to_block(half_year_blocks * 2); + + // Expected: 4 pUSD (first half at 4%) + 1 pUSD (second half at 1%) = 5 pUSD + let vault = VaultsStorage::::get(ALICE).unwrap(); + let expected_interest = 5 * PUSD; + assert_approx_eq( + vault.accrued_interest, + expected_interest, + PUSD / 2, + "Fee decrease should not retroactively reduce interest", + ); + }); + } + + /// **Test: Vault updated after fee change uses only the new rate** + /// + /// If a vault's `last_fee_update` is after the fee change timestamp, + /// `PreviousStabilityFee` should be ignored entirely. + #[test] + fn vault_updated_after_fee_change_uses_new_rate_only() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Advance 3 months — on_idle pokes the vault + let quarter_year_blocks = 1_314_000u64; + jump_to_block(quarter_year_blocks); + + // Governance changes fee from 4% to 10% + assert_ok!(Vaults::set_stability_fee(RuntimeOrigin::root(), Permill::from_percent(10))); + + // Record interest accrued so far (3 months at 4%) + let vault = VaultsStorage::::get(ALICE).unwrap(); + let interest_before = vault.accrued_interest; + let expected_before = 2 * PUSD; // 200 × 4% × 0.25 = 2 pUSD + assert_approx_eq( + interest_before, + expected_before, + PUSD / 2, + "Interest before fee change", + ); + + // Advance another 3 months — vault was updated AFTER the fee change, + // so only the new 10% rate applies for this period + jump_to_block(quarter_year_blocks * 2); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let new_interest = vault.accrued_interest - interest_before; + let expected_new = 5 * PUSD; // 200 × 10% × 0.25 = 5 pUSD + assert_approx_eq( + new_interest, + expected_new, + PUSD / 2, + "After fee change, only new rate should apply", + ); + }); + } +} + +mod edge_cases { + use super::*; + + /// **Test: Collateralization ratio is max without debt** + /// + /// When a vault has no debt, its collateralization ratio returns + /// `FixedU128::max_value()` representing infinite CR (healthy). + #[test] + fn collateralization_ratio_max_without_debt() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let ratio = VaultsPallet::::get_collateralization_ratio(&vault, &ALICE).unwrap(); + + // Without debt, ratio is max value (infinite CR = healthy) + assert_eq!(ratio, FixedU128::max_value()); + }); + } + + /// **Test: `MinimumDeposit` requirement** + /// + /// The protocol requires a minimum deposit (100 DOT) to create a vault. + /// This prevents spam/dust vaults and ensures each vault has meaningful + /// economic value to make liquidation worthwhile. + #[test] + fn fails_below_minimum_deposit() { + build_and_execute(|| { + // `MinimumDeposit` is 100 DOT, try with 99 DOT + let below_minimum = 99 * DOT; + + assert_noop!( + Vaults::create_vault(RuntimeOrigin::signed(ALICE), below_minimum), + Error::::BelowMinimumDeposit + ); + + // Zero deposit should also fail + assert_noop!( + Vaults::create_vault(RuntimeOrigin::signed(ALICE), 0), + Error::::BelowMinimumDeposit + ); + + // Exactly minimum should work + let minimum = 100 * DOT; + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), minimum)); + }); + } +} + +mod liquidation_edge_cases { + use super::*; + + /// **Test: Liquidation accrues interest and applies penalty** + /// + /// When liquidating a vault with accrued interest, the protocol + /// calculates both the stability fees and liquidation penalty. + /// Interest is accrued via `on_idle` (stale vault processing) or during liquidation, + /// and penalty is included in the auction tab. + #[test] + fn liquidation_with_accrued_interest() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Advance time to accrue interest. + jump_to_block(2_628_000); // ~6 months + + // Drop price to trigger liquidation (below 180% minimum) + set_mock_price(Some(FixedU128::from_u32(3))); + + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Vault should be in liquidation status + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::InLiquidation); + + // Should have both events: + // - InterestAccrued from on_idle during jump_to_block + // - LiquidationPenaltyAdded from liquidate_vault + let events = System::events(); + let has_interest = events + .iter() + .any(|e| matches!(e.event, RuntimeEvent::Vaults(Event::InterestAccrued { .. }))); + let has_penalty = events.iter().any(|e| { + matches!(e.event, RuntimeEvent::Vaults(Event::LiquidationPenaltyAdded { .. })) + }); + + assert!(has_interest, "Should emit `InterestAccrued` event"); + assert!(has_penalty, "Should emit `LiquidationPenaltyAdded` event"); + }); + } + + /// **Test: Liquidation boundary at exactly 180% ratio** + /// + /// Vaults at EXACTLY the minimum ratio (180%) are NOT liquidatable. + /// Liquidation only triggers when the ratio drops BELOW 180%. + /// This test verifies the boundary condition precisely. + #[test] + fn liquidation_at_exact_boundary() { + build_and_execute(|| { + // Set up vault that will be exactly at 180% after price drop + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Mint 200 pUSD (safe at 200% ICR) + let mint_amount = 200 * PUSD; + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Set price so ratio is exactly 180% + // Need: 100 DOT * price = 200 * 1.8 = 360 USD + // price = 3.60 USD/DOT + set_mock_price(Some(FixedU128::from_rational(360, 100))); + + // At exactly 180%, vault should NOT be liquidatable (ratio >= min_ratio) + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultIsSafe + ); + + // Drop price slightly below boundary + set_mock_price(Some(FixedU128::from_rational(359, 100))); + + // Now liquidation should work + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + }); + } + + /// **Test: Liquidation in severely underwater scenario** + /// + /// All collateral is always seized for auction during liquidation. + /// In underwater scenarios, the auction cannot cover the full principal. + /// + /// Only unpaid PRINCIPAL becomes bad debt (unbacked stablecoin in circulation). + /// Interest and penalty are simply not collected if there's insufficient collateral. + /// + /// Example: At $0.50/DOT, 100 DOT = $50 value, principal = 200 pUSD. + /// Auction raises 50 pUSD, which pays down principal first. + /// Remaining principal = 200 - 50 = 150 pUSD becomes bad debt. + #[test] + fn liquidation_in_underwater_scenario() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Mint 200 pUSD (at 200% ratio with initial price $4.21/DOT) + let mint_amount = 200 * PUSD; + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Crash price severely - collateral value drops dramatically + // At $0.50/DOT: 100 DOT = $50 value, principal = 200 pUSD + // Ratio = 50/200 = 25% (way under 180% minimum) + let crash_price = FixedU128::from_rational(50, 100); // $0.50/DOT + set_mock_price(Some(crash_price)); + + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Vault should be in liquidation with all collateral seized + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::InLiquidation); + + // Simulate auction completion: + // - Collateral value in pUSD = raw_price × deposit × (PUSD/DOT) for decimal conversion + // - Payment priority: principal first (per Tab::apply_payment) + // - Bad debt = remaining principal + interest (unbacked pUSD) + // - Unpaid penalty is NOT bad debt (lost protocol revenue) + let collateral_value_pusd = crash_price.saturating_mul_int(deposit) * PUSD / DOT; + let remaining_principal = mint_amount.saturating_sub(collateral_value_pusd); + // Penalty = 13% of principal = 26 pUSD, fully unpaid in crash scenario + let penalty = LiquidationPenalty::::get().unwrap().mul_floor(mint_amount); + let remaining_debt = + DebtComponents { principal: remaining_principal, interest: 0, penalty }; + let bad_debt_before = BadDebt::::get(); + + assert_ok!(Vaults::complete_auction(&ALICE, 0, remaining_debt, &BOB, 0)); + + // Bad debt = remaining principal + interest only (penalty excluded) + let expected_bad_debt = remaining_principal; // interest is 0 + assert_eq!( + BadDebt::::get(), + bad_debt_before + expected_bad_debt, + "Only unpaid principal+interest becomes bad debt, not penalty" + ); + + // Vault should be removed after auction + assert!(VaultsStorage::::get(ALICE).is_none()); + + // AuctionShortfall event uses full remaining_debt.total() + System::assert_has_event( + Event::::AuctionShortfall { shortfall: remaining_debt.total() }.into(), + ); + System::assert_has_event( + Event::::BadDebtAccrued { owner: ALICE, amount: expected_bad_debt }.into(), + ); + }); + } +} + +mod interest_edge_cases { + use super::*; + + /// **Test: No interest without debt** + /// + /// Stability fees only apply to borrowed amounts. A vault with only + /// collateral (no pUSD minted) accrues zero interest regardless of time. + #[test] + fn no_interest_accrues_without_debt() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Don't mint anything - no debt + + // Advance significant time + jump_to_block(5_256_000); // 1 year + + // Trigger fee update + assert_ok!(Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), 0)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.accrued_interest, 0, "No interest without debt"); + assert_eq!(vault.get_held_collateral(&ALICE), deposit, "Full collateral available"); + }); + } + + /// **Test: No interest accrues within the same block** + /// + /// Interest is calculated based on block time elapsed. Multiple operations + /// in the same block don't accrue additional interest because no time + /// has passed between them. + #[test] + fn no_interest_in_same_block() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + // Multiple operations in same block should not accrue interest + assert_ok!(Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), DOT)); + assert_ok!(Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), DOT)); + assert_ok!(Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), DOT)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.accrued_interest, 0, "No interest in same block"); + }); + } + + /// **Test: Frequent pokes cannot discard sub-unit interest** + /// + /// If elapsed interest rounds down to zero, `last_fee_update` must not advance, + /// otherwise a user could keep poking and permanently avoid accrual. + #[test] + fn sub_unit_interest_does_not_advance_fee_timestamp() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 5 * PUSD)); + + let initial_timestamp = VaultsStorage::::get(ALICE).unwrap().last_fee_update; + + // One block of elapsed time is too small to accrue even one pUSD base unit. + run_to_block(2); + assert_ok!(Vaults::poke(RuntimeOrigin::signed(BOB), ALICE)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.accrued_interest, 0, "Per-block poke should still round to zero"); + assert_eq!( + vault.last_fee_update, initial_timestamp, + "Zero rounded accrual must not advance the fee timestamp" + ); + + // After enough time accumulates, a later poke must accrue interest. + run_to_block(30); + assert_ok!(Vaults::poke(RuntimeOrigin::signed(BOB), ALICE)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert!( + vault.accrued_interest > 0, + "Accumulated elapsed time should eventually accrue" + ); + assert!( + vault.last_fee_update > initial_timestamp, + "Fee timestamp should advance once non-zero interest is charged" + ); + }); + } +} + +mod boundary_conditions { + use super::*; + + /// **Test: Maximum borrowing at exactly 200% ratio boundary** + /// + /// Users can borrow up to the exact point where their collateralization + /// ratio equals 200% (initial ratio). Attempting to borrow even 1 more + /// pUSD unit will fail. + #[test] + fn mint_at_exact_initial_ratio() { + build_and_execute(|| { + // Oracle: 1 DOT = 4.21 USD + // 100 DOT = 421 USD + // At exactly 200%: max_mint = 421 / 2.0 = 210.5 pUSD + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Calculate exact max mint + // collateral_value = 100 DOT * 4.21 USD = 421 USD + // 421 USD / 2.0 = 210.5 pUSD + // Try 210 pUSD - should work + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 210 * PUSD)); + + // Try to mint 5 more pUSD - should fail (would be below 200%) + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 5 * PUSD), + Error::::UnsafeCollateralizationRatio + ); + }); + } + + /// **Test: Dust amount minting is rejected** + /// + /// The system rejects mints below the `MinimumMint` threshold (5 pUSD). + #[test] + fn dust_amounts_handled_correctly() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Mint a very small amount (1 smallest unit of pUSD) - should fail + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 1), + Error::::BelowMinimumMint + ); + + // Mint just below minimum (4.999999 pUSD) - should fail + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 5 * PUSD - 1), + Error::::BelowMinimumMint + ); + + // Mint exactly minimum (5 pUSD) - should succeed + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 5 * PUSD)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 5 * PUSD); + + // Repay works for any amount + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), 5 * PUSD)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 0); + }); + } + + /// **Test: Zero deposit still triggers fee update** + /// + /// Depositing 0 collateral is a valid operation that triggers fee + /// calculation and interest collection. This allows users to + /// voluntarily settle their accrued interest without changing position. + #[test] + fn zero_deposit_collateral_triggers_fee_update() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + let vault_before = VaultsStorage::::get(ALICE).unwrap(); + let last_update_before = vault_before.last_fee_update; + + // Advance time + run_to_block(1000); + + // Deposit 0 - should still trigger fee update + assert_ok!(Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), 0)); + + let vault_after = VaultsStorage::::get(ALICE).unwrap(); + assert!( + vault_after.last_fee_update > last_update_before, + "Fee update timestamp should advance" + ); + }); + } +} + +mod poke { + use super::*; + + /// **Test: Poke fails for non-existent vault** + /// + /// Cannot poke a vault that doesn't exist. + #[test] + fn poke_fails_for_nonexistent_vault() { + build_and_execute(|| { + assert_noop!( + Vaults::poke(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultNotFound + ); + }); + } + + /// **Test: Poke updates vault fee timestamp and accrues interest** + /// + /// When a vault has debt, poking it can trigger fee calculations. + /// After 1 year, the vault becomes stale and `on_idle` processes it. + /// This test verifies that poke works correctly in this scenario. + #[test] + fn poke_accrues_interest_and_emits_event() { + build_and_execute(|| { + let deposit = 100 * DOT; + let mint_amount = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + + let initial_collateral = + VaultsStorage::::get(ALICE).unwrap().get_held_collateral(&ALICE); + + // Advance 1 year. + jump_to_block(5_256_000); + + // Verify interest was accrued + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert!(vault.accrued_interest > 0, "Interest should be accrued"); + + // With 4% annual fee on 200 pUSD = 8 pUSD + let expected = 8 * PUSD; + assert_approx_eq( + vault.accrued_interest, + expected, + INTEREST_TOLERANCE, + "Interest after 1 year", + ); + + // Poke is still callable (even though vault was just updated) + assert_ok!(Vaults::poke(RuntimeOrigin::signed(CHARLIE), ALICE)); + + // Collateral should NOT be reduced (interest not collected yet) + let vault = VaultsStorage::::get(ALICE).unwrap(); + let final_collateral = vault.get_held_collateral(&ALICE); + assert_eq!( + final_collateral, initial_collateral, + "Collateral should not be reduced by poke" + ); + + // `InterestAccrued` event should be emitted + let events = System::events(); + let has_interest_event = events + .iter() + .any(|e| matches!(e.event, RuntimeEvent::Vaults(Event::InterestAccrued { .. }))); + assert!(has_interest_event, "Should emit `InterestAccrued` event"); + }); + } + + /// **Test: Vault owner can poke their own vault** + /// + /// The vault owner can also call poke on their own vault. + #[test] + fn owner_can_poke_own_vault() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Record timestamp before advancing time + let vault_before = VaultsStorage::::get(ALICE).unwrap(); + let timestamp_before = vault_before.last_fee_update; + + run_to_block(1000); + + // Owner pokes own vault - should succeed + assert_ok!(Vaults::poke(RuntimeOrigin::signed(ALICE), ALICE)); + + // Vault's last_fee_update should be updated to current timestamp + let vault = VaultsStorage::::get(ALICE).unwrap(); + let current_timestamp = MockTimestamp::get(); + assert_eq!( + vault.last_fee_update, current_timestamp, + "last_fee_update should be updated to current timestamp" + ); + assert!( + vault.last_fee_update > timestamp_before, + "last_fee_update should have advanced from initial value" + ); + }); + } +} + +mod heal_permissionless { + use super::*; + + /// **Test: Anyone can trigger bad debt repayment from `InsuranceFund`** + /// + /// Bad debt repayment burns pUSD from the `InsuranceFund` account. + /// Any user can call `heal` to trigger this. + #[test] + fn anyone_can_heal() { + build_and_execute(|| { + // Setup: Create some bad debt + BadDebt::::put(100 * PUSD); + + // Give `InsuranceFund` pUSD to burn + assert_ok!(Assets::mint_into(STABLECOIN_ASSET_ID, &INSURANCE_FUND, 200 * PUSD)); + + let insurance_before = Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND); + + // ALICE calls heal to repay bad debt from `InsuranceFund` + assert_ok!(Vaults::heal(RuntimeOrigin::signed(ALICE), 50 * PUSD)); + + // Bad debt should be reduced + assert_eq!(BadDebt::::get(), 50 * PUSD); + + // `InsuranceFund`'s pUSD should be burned + assert_eq!( + Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND), + insurance_before - 50 * PUSD + ); + + // Event should show the amount repaid + System::assert_has_event(Event::::BadDebtRepaid { amount: 50 * PUSD }.into()); + }); + } + + /// **Test: Multiple `heal` calls burn from `InsuranceFund`** + /// + /// Different users can call `heal` multiple times, each burning from `InsuranceFund`. + #[test] + fn multiple_users_can_heal() { + build_and_execute(|| { + // Setup: Create bad debt + BadDebt::::put(300 * PUSD); + + // Give `InsuranceFund` pUSD to burn + assert_ok!(Assets::mint_into(STABLECOIN_ASSET_ID, &INSURANCE_FUND, 300 * PUSD)); + + // Each user calls heal + assert_ok!(Vaults::heal(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + assert_eq!(BadDebt::::get(), 200 * PUSD); + + assert_ok!(Vaults::heal(RuntimeOrigin::signed(BOB), 100 * PUSD)); + assert_eq!(BadDebt::::get(), 100 * PUSD); + + assert_ok!(Vaults::heal(RuntimeOrigin::signed(CHARLIE), 100 * PUSD)); + assert_eq!(BadDebt::::get(), 0); + + // All `InsuranceFund` pUSD should be burned + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND), 0); + }); + } + + /// **Test: Repayment capped to actual bad debt** + /// + /// If a user tries to repay more than the current bad debt, + /// only the actual bad debt amount is burned from `InsuranceFund`. + #[test] + fn repayment_capped_to_actual_bad_debt() { + build_and_execute(|| { + // Setup: Small bad debt + BadDebt::::put(50 * PUSD); + + // Give `InsuranceFund` more pUSD than bad debt + assert_ok!(Assets::mint_into(STABLECOIN_ASSET_ID, &INSURANCE_FUND, 200 * PUSD)); + + // Try to repay more than bad debt + assert_ok!(Vaults::heal(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Only 50 pUSD should be burned from `InsuranceFund` + assert_eq!(BadDebt::::get(), 0); + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND), 150 * PUSD); + + // Event should show actual amount + System::assert_has_event(Event::::BadDebtRepaid { amount: 50 * PUSD }.into()); + }); + } + + /// **Test: No-op when no bad debt exists** + /// + /// Attempting to repay when there's no bad debt does nothing. + #[test] + fn noop_when_no_bad_debt() { + build_and_execute(|| { + // No bad debt + assert_eq!(BadDebt::::get(), 0); + + // Give `InsuranceFund` some pUSD + assert_ok!(Assets::mint_into(STABLECOIN_ASSET_ID, &INSURANCE_FUND, 100 * PUSD)); + + // Try to repay - should succeed but do nothing + assert_ok!(Vaults::heal(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + // No pUSD should be burned from `InsuranceFund` + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND), 100 * PUSD); + + // No event should be emitted (check no BadDebtRepaid event) + let events = System::events(); + let has_repaid_event = events + .iter() + .any(|e| matches!(e.event, RuntimeEvent::Vaults(Event::BadDebtRepaid { .. }))); + assert!(!has_repaid_event, "No event when no bad debt to repay"); + }); + } + + /// **Test: No-op if `InsuranceFund` has insufficient pUSD** + /// + /// `heal` uses best-effort burning, so if the Insurance Fund has no pUSD + /// available it should succeed without changing bad debt. + #[test] + fn noop_with_insufficient_pusd() { + build_and_execute(|| { + // Setup: Bad debt exists + BadDebt::::put(100 * PUSD); + + // `InsuranceFund` has no pUSD + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND), 0); + + // Should succeed but burn nothing. + assert_ok!(Vaults::heal(RuntimeOrigin::signed(ALICE), 50 * PUSD)); + + // Bad debt unchanged + assert_eq!(BadDebt::::get(), 100 * PUSD); + + // No repayment event should be emitted. + let events = System::events(); + let has_repaid_event = events + .iter() + .any(|e| matches!(e.event, RuntimeEvent::Vaults(Event::BadDebtRepaid { .. }))); + assert!(!has_repaid_event, "No event when no pUSD could be burned"); + }); + } +} + +mod vault_status { + use super::*; + + /// **Test: New vaults start with `Healthy` status** + #[test] + fn new_vault_is_healthy() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::Healthy); + }); + } + + /// **Test: Cannot deposit to vault in liquidation** + #[test] + fn cannot_deposit_to_liquidating_vault() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + assert_noop!( + Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), 10 * DOT), + Error::::VaultInLiquidation + ); + }); + } + + /// **Test: Cannot withdraw from vault in liquidation** + #[test] + fn cannot_withdraw_from_liquidating_vault() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + assert_noop!( + Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 10 * DOT), + Error::::VaultInLiquidation + ); + }); + } + + /// **Test: Cannot mint from vault in liquidation** + #[test] + fn cannot_mint_from_liquidating_vault() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 10 * PUSD), + Error::::VaultInLiquidation + ); + }); + } + + /// **Test: Cannot repay vault in liquidation** + #[test] + fn cannot_repay_liquidating_vault() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + assert_noop!( + Vaults::repay(RuntimeOrigin::signed(ALICE), 10 * PUSD), + Error::::VaultInLiquidation + ); + }); + } + + /// **Test: Cannot close vault in liquidation** + #[test] + fn cannot_close_liquidating_vault() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + assert_noop!( + Vaults::close_vault(RuntimeOrigin::signed(ALICE)), + Error::::VaultInLiquidation + ); + }); + } + + /// **Test: Cannot poke vault in liquidation** + #[test] + fn cannot_poke_liquidating_vault() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + assert_noop!( + Vaults::poke(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultInLiquidation + ); + }); + } + + /// **Test: `complete_auction` removes vault immediately** + #[test] + fn auction_completed_removes_vault() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Vault exists with InLiquidation status + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::InLiquidation); + + // Simulate auction completion + assert_ok!(Vaults::complete_auction(&ALICE, 0, DebtComponents::default(), &BOB, 0)); + + // Vault should be removed immediately (no deferred cleanup) + assert!(VaultsStorage::::get(ALICE).is_none()); + + // `VaultClosed` event should be emitted + System::assert_has_event(Event::::VaultClosed { owner: ALICE }.into()); + }); + } + + /// **Test: `on_idle` respects weight limits for fee updates** + #[test] + fn on_idle_respects_weight_limit() { + build_and_execute(|| { + // Create vaults + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(BOB), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(BOB), 200 * PUSD)); + + // Record initial timestamps + let initial_timestamp_a = VaultsStorage::::get(ALICE).unwrap().last_fee_update; + let initial_timestamp_b = VaultsStorage::::get(BOB).unwrap().last_fee_update; + + // Make vaults stale by advancing timestamp beyond threshold + let stale_threshold = StaleVaultThreshold::::get().expect("set in genesis; qed"); + advance_timestamp(stale_threshold + 1); + + // Run on_idle with zero weight - should not do anything + let weight = Vaults::on_idle(1, Weight::zero()); + assert_eq!(weight, Weight::zero()); + + // Both vaults should still have old last_fee_update. + let vault_a = VaultsStorage::::get(ALICE).unwrap(); + let vault_b = VaultsStorage::::get(BOB).unwrap(); + assert_eq!(vault_a.last_fee_update, initial_timestamp_a); + assert_eq!(vault_b.last_fee_update, initial_timestamp_b); + }); + } + + /// **Test: Can create new vault immediately after auction completion** + #[test] + fn can_create_vault_after_auction_completion() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + assert_ok!(Vaults::complete_auction(&ALICE, 0, DebtComponents::default(), &BOB, 0)); + + // Vault is removed immediately - ALICE can create a new vault right away + set_mock_price(Some(FixedU128::from_rational(421, 100))); + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::Healthy); + }); + } + + /// **Test: Operations fail on non-existent vault after liquidation** + /// + /// After complete_auction, the vault is removed, so operations + /// fail with VaultNotFound instead of VaultInLiquidation. + #[test] + fn operations_fail_after_vault_liquidated() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + assert_ok!(Vaults::complete_auction(&ALICE, 0, DebtComponents::default(), &BOB, 0)); + + // Vault no longer exists - operations fail with VaultNotFound + assert_noop!( + Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), 10 * DOT), + Error::::VaultNotFound + ); + assert_noop!( + Vaults::poke(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultNotFound + ); + }); + } +} + +mod liquidation_limits { + use super::*; + + /// **Test: Liquidation blocked when `MaxLiquidationAmount` exceeded** + /// + /// The `MaxLiquidationAmount` parameter is a HARD limit. + /// Liquidations are blocked when `CurrentLiquidationAmount` + debt > max. + #[test] + fn liquidation_blocked_when_max_exceeded() { + build_and_execute(|| { + // Create a vault and mint while limits are high + let deposit = 100 * DOT; + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Now lower MaxLiquidationAmount to a very low value (100 pUSD) + assert_ok!(Vaults::set_max_position_amount(RuntimeOrigin::root(), 100 * PUSD)); + assert_ok!(Vaults::set_max_liquidation_amount(RuntimeOrigin::root(), 100 * PUSD)); + + // Drop price to make vault undercollateralized + set_mock_price(Some(FixedU128::from_u32(2))); // $2 per DOT + + // Liquidation should FAIL because tab (226 pUSD) > MaxLiquidationAmount (100 pUSD) + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::ExceedsMaxLiquidationAmount + ); + + // CurrentLiquidationAmount should remain at 0 + assert_eq!(CurrentLiquidationAmount::::get(), 0); + + // Vault should still be healthy (liquidation didn't proceed) + let vault = VaultsStorage::::get(ALICE).expect("Vault should still exist"); + assert_eq!(vault.status, VaultStatus::Healthy); + }); + } + + /// **Test: Liquidation succeeds when within limit** + /// + /// When `CurrentLiquidationAmount` + tab <= `MaxLiquidationAmount`, liquidations + /// proceed normally and `CurrentLiquidationAmount` is updated to track the new auction. + #[test] + fn liquidation_updates_current_amount() { + build_and_execute(|| { + // MaxLiquidationAmount is 20,000,000 pUSD by default (from genesis) + assert_eq!(MaxLiquidationAmount::::get(), Some(20_000_000 * PUSD)); + assert_eq!(CurrentLiquidationAmount::::get(), 0); + + // Create a vault + let deposit = 100 * DOT; + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Drop price to make vault undercollateralized (below 180%) + set_mock_price(Some(FixedU128::from_u32(3))); // $3 per DOT + + // Liquidation should succeed + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // CurrentLiquidationAmount tracks total debt (principal + interest + penalty) + let expected_principal = 200 * PUSD; + let penalty = LiquidationPenalty::::get().unwrap().mul_floor(expected_principal); + let expected_total = expected_principal + penalty; // no interest accrued + assert_eq!(CurrentLiquidationAmount::::get(), expected_total); + }); + } + + /// **Test: `set_max_liquidation_amount` governance function** + /// + /// Governance can update the `MaxLiquidationAmount` parameter to control + /// liquidation throughput. + #[test] + fn set_max_liquidation_amount_works() { + build_and_execute(|| { + let old_max = 20_000_000 * PUSD; // Genesis default + let new_max = 500_000 * PUSD; + + // Also lower MaxPositionAmount to maintain invariant + assert_ok!(Vaults::set_max_position_amount(RuntimeOrigin::root(), new_max)); + assert_ok!(Vaults::set_max_liquidation_amount(RuntimeOrigin::root(), new_max)); + assert_eq!(MaxLiquidationAmount::::get(), Some(new_max)); + + System::assert_has_event( + Event::::MaxLiquidationAmountUpdated { + old_value: old_max, + new_value: new_max, + } + .into(), + ); + }); + } + + /// **Test: `set_max_liquidation_amount` requires `ManagerOrigin`** + /// + /// Only `ManagerOrigin` can modify the `MaxLiquidationAmount` parameter. + #[test] + fn set_max_liquidation_amount_requires_manager_origin() { + build_and_execute(|| { + assert_noop!( + Vaults::set_max_liquidation_amount(RuntimeOrigin::signed(ALICE), 500_000 * PUSD), + frame_support::error::BadOrigin + ); + }); + } + + /// **Test: `on_auction_debt_collected` reduces `CurrentLiquidationAmount`** + /// + /// When the Auctions pallet collects pUSD from a bidder, it calls back + /// to reduce `CurrentLiquidationAmount`, freeing up room for more liquidations. + #[test] + fn auction_callback_reduces_current_amount() { + build_and_execute(|| { + // Manually set CurrentLiquidationAmount to simulate an active auction + CurrentLiquidationAmount::::put(1000 * PUSD); + + // Simulate auction collecting 400 pUSD + assert_ok!(Vaults::test_reduce_liquidation_amount(400 * PUSD)); + + assert_eq!(CurrentLiquidationAmount::::get(), 600 * PUSD); + + System::assert_has_event( + Event::::AuctionDebtCollected { amount: 400 * PUSD }.into(), + ); + }); + } + + #[test] + fn auction_callback_fails_on_underflow() { + build_and_execute(|| { + CurrentLiquidationAmount::::put(100 * PUSD); + + assert_noop!( + Vaults::test_reduce_liquidation_amount(400 * PUSD), + Error::::ArithmeticUnderflow + ); + + assert_eq!(CurrentLiquidationAmount::::get(), 100 * PUSD); + }); + } + + /// **Test: Auction shortfall increases `BadDebt` and reduces `CurrentLiquidationAmount`** + /// + /// When an auction completes with remaining debt, `CurrentLiquidationAmount` is reduced + /// by the total remainder, and `BadDebt` is accrued for principal + interest only. + #[test] + fn auction_shortfall_increases_bad_debt_and_reduces_current_amount() { + build_and_execute(|| { + // Setup: Simulate an auction started with 1000 pUSD total exposure + CurrentLiquidationAmount::::put(1000 * PUSD); + + // Initially no bad debt + assert_eq!(BadDebt::::get(), 0); + + // Simulate auction completing with remaining debt + let remaining = + DebtComponents { principal: 400 * PUSD, interest: 50 * PUSD, penalty: 50 * PUSD }; + assert_ok!(Vaults::test_record_shortfall(ALICE, remaining)); + + // BadDebt = principal + interest only (not penalty) + assert_eq!(BadDebt::::get(), 450 * PUSD); + + // CurrentLiquidationAmount decreases by total remainder (500) + assert_eq!(CurrentLiquidationAmount::::get(), 500 * PUSD); + + // Events should be emitted + System::assert_has_event( + Event::::AuctionShortfall { shortfall: 500 * PUSD }.into(), + ); + System::assert_has_event( + Event::::BadDebtAccrued { owner: ALICE, amount: 450 * PUSD }.into(), + ); + + // Multiple shortfalls accumulate + let remaining2 = + DebtComponents { principal: 150 * PUSD, interest: 0, penalty: 50 * PUSD }; + assert_ok!(Vaults::test_record_shortfall(ALICE, remaining2)); + // BadDebt += 150 (principal only, no interest) + assert_eq!(BadDebt::::get(), 600 * PUSD); + // CLA -= 200 (total of remaining2) + assert_eq!(CurrentLiquidationAmount::::get(), 300 * PUSD); + System::assert_has_event( + Event::::BadDebtAccrued { owner: ALICE, amount: 150 * PUSD }.into(), + ); + }); + } + + #[test] + fn auction_shortfall_fails_on_underflow() { + build_and_execute(|| { + CurrentLiquidationAmount::::put(100 * PUSD); + assert_eq!(BadDebt::::get(), 0); + + let remaining = + DebtComponents { principal: 300 * PUSD, interest: 50 * PUSD, penalty: 50 * PUSD }; + assert_noop!( + Vaults::test_record_shortfall(ALICE, remaining), + Error::::ArithmeticUnderflow + ); + + assert_eq!(CurrentLiquidationAmount::::get(), 100 * PUSD); + assert_eq!(BadDebt::::get(), 0); + }); + } + + /// **Test: Multiple liquidations track cumulative `CurrentLiquidationAmount`** + /// + /// When multiple vaults are liquidated, `CurrentLiquidationAmount` accumulates correctly. + /// At $2/DOT: 100 DOT = $200 collateral value. + /// With 200 pUSD debt: ratio = 200/200 = 100% (< 130% minimum, liquidatable). + #[test] + fn multiple_liquidations_accumulate_current_amount() { + build_and_execute(|| { + // Create two vaults with same parameters + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(BOB), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(BOB), 200 * PUSD)); + + // Drop price to make both vaults undercollateralized + set_mock_price(Some(FixedU128::from_u32(2))); // $2 per DOT + + // Liquidate both + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(CHARLIE), ALICE)); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(CHARLIE), BOB)); + + // CurrentLiquidationAmount = sum of total debts for both vaults + // Each vault has 200 pUSD principal + 13% penalty = 226 pUSD (no interest) + let principal_per_vault = 200 * PUSD; + let penalty_per_vault = + LiquidationPenalty::::get().unwrap().mul_floor(principal_per_vault); + let total_per_vault = principal_per_vault + penalty_per_vault; + let expected_total = total_per_vault * 2; + assert_eq!(CurrentLiquidationAmount::::get(), expected_total); + }); + } + + /// **Test: `CurrentLiquidationAmount` returns to zero after complete auction** + /// + /// This is an end-to-end test that verifies the counter properly tracks + /// total debt and returns to zero when all debt components are paid off. + #[test] + fn current_liquidation_amount_returns_to_zero_after_complete_auction() { + build_and_execute(|| { + // Start with zero + assert_eq!(CurrentLiquidationAmount::::get(), 0); + + // Create and liquidate vault + let principal = 200 * PUSD; + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), principal)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Counter should equal total debt (principal + penalty) + let penalty = LiquidationPenalty::::get().unwrap().mul_floor(principal); + let total_debt = principal + penalty; + assert_eq!(CurrentLiquidationAmount::::get(), total_debt); + + // Simulate a full purchase that pays off all debt components + let payment = PaymentBreakdown::new( + principal, // principal_paid + 0, // interest_paid (no interest for simplicity) + penalty, // penalty_paid (13% penalty) + ); + + // Give buyer enough pUSD + Assets::mint_into(STABLECOIN_ASSET_ID, &BOB, payment.total()).unwrap(); + + // Execute purchase - this decrements the counter by payment.total() + assert_ok!(Vaults::execute_purchase(&BOB, 100 * DOT, payment, &CHARLIE, &ALICE,)); + + // Counter should be zero after all debt is fully paid + assert_eq!(CurrentLiquidationAmount::::get(), 0); + + // Complete auction - no remaining debt + assert_ok!(Vaults::complete_auction(&ALICE, 0, DebtComponents::default(), &BOB, 0)); + + // Counter should still be zero + assert_eq!(CurrentLiquidationAmount::::get(), 0); + }); + } + + /// **Test: Partial purchases correctly decrement counter by `payment.total()`** + /// + /// When an auction has multiple partial purchases, each one should + /// decrement `CurrentLiquidationAmount` by the total payment amount. + #[test] + fn partial_purchases_decrement_counter_by_payment_total() { + build_and_execute(|| { + let principal = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), principal)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Counter starts at total debt (principal + penalty, no interest) + let penalty = LiquidationPenalty::::get().unwrap().mul_floor(principal); + let total_debt = principal + penalty; // 200 + 26 = 226 pUSD + assert_eq!(CurrentLiquidationAmount::::get(), total_debt); + + // Simulate partial purchase paying half of principal and half of penalty + let half_principal = principal / 2; + let half_penalty = penalty / 2; + let payment = PaymentBreakdown::new( + half_principal, // principal_paid + 0, // interest_paid (no interest accrued) + half_penalty, // penalty_paid + ); + + // Give BOB enough pUSD to pay + Assets::mint_into(STABLECOIN_ASSET_ID, &BOB, payment.total()).unwrap(); + + // Execute partial purchase + assert_ok!(Vaults::execute_purchase(&BOB, 25 * DOT, payment, &CHARLIE, &ALICE,)); + + // Counter should be decremented by payment.total() + let remaining_cla = total_debt - payment.total(); + assert_eq!(CurrentLiquidationAmount::::get(), remaining_cla); + + // Complete the auction with remaining debt breakdown + let remaining_debt = DebtComponents { + principal: principal - half_principal, + interest: 0, + penalty: penalty - half_penalty, + }; + assert_ok!(Vaults::complete_auction(&ALICE, 0, remaining_debt, &BOB, 0)); + + // Counter should now be zero (decremented by remaining_debt.total()) + assert_eq!(CurrentLiquidationAmount::::get(), 0); + }); + } + + /// **Test: Multi-auction regression — one auction's completion does not erode another's + /// exposure** + /// + /// Liquidates two vaults with different debt totals, partially settles one, + /// completes it with a non-zero remainder, and verifies that the other + /// auction's exposure is preserved exactly. + #[test] + fn multi_auction_cla_isolation() { + build_and_execute(|| { + // Create two vaults with different principals + let principal_a = 200 * PUSD; + let principal_b = 300 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), principal_a)); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(BOB), 150 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(BOB), principal_b)); + + // Drop price to make both vaults undercollateralized + set_mock_price(Some(FixedU128::from_u32(3))); // $3 per DOT + + // Liquidate both vaults + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(CHARLIE), ALICE)); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(CHARLIE), BOB)); + + // Compute expected total debts + let penalty_a = LiquidationPenalty::::get().unwrap().mul_floor(principal_a); + let penalty_b = LiquidationPenalty::::get().unwrap().mul_floor(principal_b); + let total_a = principal_a + penalty_a; // 200 + 26 = 226 + let total_b = principal_b + penalty_b; // 300 + 39 = 339 + + // CLA = sum of both total debts + assert_eq!(CurrentLiquidationAmount::::get(), total_a + total_b); + + // Partially settle auction A: pay half of principal_a + some penalty + let half_principal_a = principal_a / 2; + let half_penalty_a = penalty_a / 2; + let payment_a = PaymentBreakdown::new( + half_principal_a, // principal_paid + 0, // interest_paid + half_penalty_a, // penalty_paid + ); + Assets::mint_into(STABLECOIN_ASSET_ID, &CHARLIE, payment_a.total()).unwrap(); + assert_ok!(Vaults::execute_purchase(&CHARLIE, 25 * DOT, payment_a, &CHARLIE, &ALICE,)); + + // CLA decreased by payment_a.total() + let cla_after_partial = total_a + total_b - payment_a.total(); + assert_eq!(CurrentLiquidationAmount::::get(), cla_after_partial); + + // Complete auction A with remaining debt + let remaining_a = DebtComponents { + principal: principal_a - half_principal_a, + interest: 0, + penalty: penalty_a - half_penalty_a, + }; + assert_ok!(Vaults::complete_auction(&ALICE, 0, remaining_a, &CHARLIE, 0)); + + // After auction A completes, CLA should equal exactly auction B's total debt + assert_eq!( + CurrentLiquidationAmount::::get(), + total_b, + "Auction A's completion must not erode auction B's exposure" + ); + + // Bad debt from auction A = remaining principal (no interest) + assert_eq!(BadDebt::::get(), remaining_a.principal); + + // Now complete auction B with no remainder (fully satisfied) + // First simulate full payment of auction B via execute_purchase + let payment_b = PaymentBreakdown::new( + principal_b, // principal_paid + 0, // interest_paid + penalty_b, // penalty_paid + ); + Assets::mint_into(STABLECOIN_ASSET_ID, &CHARLIE, payment_b.total()).unwrap(); + assert_ok!(Vaults::execute_purchase(&CHARLIE, 150 * DOT, payment_b, &CHARLIE, &BOB,)); + + assert_eq!(CurrentLiquidationAmount::::get(), 0); + + // Complete auction B with no remaining debt + assert_ok!(Vaults::complete_auction(&BOB, 0, DebtComponents::default(), &CHARLIE, 0)); + + // Final state: CLA = 0, BadDebt = only from auction A + assert_eq!(CurrentLiquidationAmount::::get(), 0); + assert_eq!(BadDebt::::get(), remaining_a.principal); + }); + } + + /// **Test: Unpaid penalty does NOT contribute to bad debt** + /// + /// When an auction completes with only unpaid penalty and no unpaid principal/interest, + /// no bad debt should be accrued. + #[test] + fn unpaid_penalty_only_does_not_create_bad_debt() { + build_and_execute(|| { + let principal = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), principal)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + let penalty = LiquidationPenalty::::get().unwrap().mul_floor(principal); + let total_debt = principal + penalty; + + // Simulate full principal + interest paid, but penalty unpaid + let payment = PaymentBreakdown::new(principal, 0, 0); + Assets::mint_into(STABLECOIN_ASSET_ID, &BOB, payment.total()).unwrap(); + assert_ok!(Vaults::execute_purchase(&BOB, 80 * DOT, payment, &CHARLIE, &ALICE)); + + // CLA should be decreased by principal only + assert_eq!(CurrentLiquidationAmount::::get(), total_debt - principal); + + // Complete with only penalty remaining + let remaining_debt = DebtComponents { principal: 0, interest: 0, penalty }; + assert_ok!(Vaults::complete_auction(&ALICE, 0, remaining_debt, &BOB, 0)); + + // No bad debt should be accrued (penalty is not bad debt) + assert_eq!(BadDebt::::get(), 0); + // CLA should be zero + assert_eq!(CurrentLiquidationAmount::::get(), 0); + }); + } +} + +mod missing_error_cases { + use super::*; + + /// **Test: repay fails if vault not found** + /// + /// Cannot repay debt on a non-existent vault. + #[test] + fn repay_fails_if_vault_not_found() { + build_and_execute(|| { + // ALICE has no vault + assert!(VaultsStorage::::get(ALICE).is_none()); + + // Give ALICE some pUSD to attempt repayment + assert_ok!(Assets::mint_into(STABLECOIN_ASSET_ID, &ALICE, 100 * PUSD)); + + // Should fail with VaultNotFound + assert_noop!( + Vaults::repay(RuntimeOrigin::signed(ALICE), 50 * PUSD), + Error::::VaultNotFound + ); + }); + } + + /// **Test: close_vault fails if vault not found** + /// + /// Cannot close a non-existent vault. + #[test] + fn close_vault_fails_if_vault_not_found() { + build_and_execute(|| { + // ALICE has no vault + assert!(VaultsStorage::::get(ALICE).is_none()); + + // Should fail with VaultNotFound + assert_noop!( + Vaults::close_vault(RuntimeOrigin::signed(ALICE)), + Error::::VaultNotFound + ); + }); + } + + /// **Test: liquidate fails if price not available** + /// + /// Liquidation requires a valid oracle price to calculate + /// the collateralization ratio. Without price, liquidation + /// cannot proceed. + #[test] + fn liquidate_fails_if_price_not_available() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Remove oracle price + set_mock_price(None); + + // Liquidation should fail - can't calculate collateralization ratio + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::PriceNotAvailable + ); + }); + } + + /// **Test: Cannot liquidate a vault that's already in liquidation** + /// + /// Once a vault enters liquidation, it cannot be liquidated again. + /// This prevents double-counting in the auction system and ensures + /// the vault lifecycle is properly managed. + #[test] + fn liquidate_fails_if_already_in_liquidation() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Drop price to make vault undercollateralized + set_mock_price(Some(FixedU128::from_u32(3))); + + // First liquidation succeeds + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Verify vault is in liquidation + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::InLiquidation); + + // Second liquidation attempt should fail with VaultInLiquidation + // The vault status check happens before any other processing + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(CHARLIE), ALICE), + Error::::VaultInLiquidation + ); + }); + } + + /// **Test: withdraw_collateral fails if price not available when vault has debt** + /// + /// When a vault has outstanding debt, withdrawing collateral requires + /// calculating the collateralization ratio, which needs the oracle price. + #[test] + fn withdraw_collateral_fails_if_price_not_available_with_debt() { + build_and_execute(|| { + // Use 200 DOT so we can withdraw 10 DOT and still have 190 DOT (> 100 DOT min) + let deposit = 200 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + // Mint 200 pUSD (well under max of ~421 pUSD at 200% ICR) + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Remove oracle price + set_mock_price(None); + + // Should fail because we need price to: + // 1. Calculate interest in DOT for collection + // 2. Calculate collateralization ratio after withdrawal + assert_noop!( + Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 10 * DOT), + Error::::PriceNotAvailable + ); + }); + } +} + +mod oracle_edge_cases { + use super::*; + + /// **Test: withdraw_collateral succeeds without price when vault has no debt** + /// + /// When a vault has no debt, we don't need the oracle price to withdraw + /// because there's no collateralization ratio to check and no interest + /// to collect. + #[test] + fn withdraw_succeeds_without_price_when_no_debt() { + build_and_execute(|| { + let deposit = 200 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // No debt minted - vault has no debt + + // Remove oracle price + set_mock_price(None); + + // Should succeed - no price needed when there's no debt + assert_ok!(Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 50 * DOT)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.get_held_collateral(&ALICE), 150 * DOT); + }); + } + + /// **Test: close_vault succeeds without price** + /// + /// The simplified interest model calculates interest purely in pUSD, + /// so close_vault never needs the oracle price. + #[test] + fn close_vault_succeeds_without_price() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Remove oracle price + set_mock_price(None); + + // Should succeed - close_vault doesn't need price + assert_ok!(Vaults::close_vault(RuntimeOrigin::signed(ALICE))); + + // Vault should be removed + assert!(VaultsStorage::::get(ALICE).is_none()); + }); + } +} + +mod mint_edge_cases { + use super::*; + + /// **Test: Minting zero amount fails with BelowMinimumMint** + /// + /// Minting 0 pUSD is not allowed per the spec. Users must mint at least + /// MinimumMint (5 pUSD). To trigger fee updates without minting, use `poke()`. + #[test] + fn mint_zero_amount_fails() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // First mint some debt + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 100 * PUSD); + + // Mint 0 pUSD - should fail (below MinimumMint) + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 0), + Error::::BelowMinimumMint + ); + + // Principal unchanged + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 100 * PUSD, "Principal should remain unchanged"); + }); + } + + /// **Test: Multiple mints accumulate debt correctly** + /// + /// Users can mint pUSD multiple times, with each mint adding to + /// the total debt. The collateralization ratio must remain safe + /// after each mint. + #[test] + fn mint_multiple_times_accumulates_debt() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // First mint: 100 pUSD + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 100 * PUSD); + + // Second mint: 50 pUSD + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 50 * PUSD)); + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 150 * PUSD); + + // Third mint: 30 pUSD + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 30 * PUSD)); + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 180 * PUSD); + + // Total pUSD minted + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, ALICE), 180 * PUSD); + }); + } + + /// **Test: Accrued interest reduces available minting capacity** + /// + /// When a vault has accrued interest, the total obligation (debt + interest) + /// is used for collateralization ratio calculation, reducing how much + /// additional pUSD can be minted. + #[test] + fn mint_after_significant_interest_reduces_available_credit() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Mint 100 pUSD (ratio = 421/100 = 421%) + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + // Advance 1 year to accrue 4% interest = 4 pUSD. + jump_to_block(5_256_000); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert!(vault.accrued_interest > 0, "Should have accrued interest"); + + // Now total obligation = 100 + ~4 = ~104 pUSD + // At 200% initial ratio: max_total = 421 / 2.0 = 210.5 pUSD + // Available to mint = 210.5 - 104 = ~106.5 pUSD + + // Try to mint 110 pUSD - should fail (would exceed with interest) + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 110 * PUSD), + Error::::UnsafeCollateralizationRatio + ); + + // Mint 100 pUSD - should succeed + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + }); + } + + /// **Test: Minting beyond `MaxPositionAmount` fails** + /// + /// When vault principal would exceed `MaxPositionAmount`, the mint + /// must be rejected with `ExceedsMaxPositionAmount`. + #[test] + fn mint_exceeds_max_position_amount() { + build_and_execute(|| { + // Lower MaxPositionAmount to something testable + assert_ok!(Vaults::set_max_position_amount(RuntimeOrigin::root(), 100 * PUSD)); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + // Mint up to the limit + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + // Mint more should fail + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 5 * PUSD), + Error::::ExceedsMaxPositionAmount + ); + }); + } + + /// **Test: Cannot mint after vault removed (post-auction)** + /// + /// Once a vault is removed after auction completion, + /// minting fails with VaultNotFound. Users must create a new vault. + #[test] + fn mint_to_liquidated_vault_fails() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Trigger liquidation + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Complete auction - vault is immediately removed + assert_ok!(Vaults::complete_auction(&ALICE, 0, DebtComponents::default(), &BOB, 0)); + + // Vault should be removed + assert!(VaultsStorage::::get(ALICE).is_none()); + + // Reset price + set_mock_price(Some(FixedU128::from_rational(421, 100))); + + // Cannot mint - vault doesn't exist + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 10 * PUSD), + Error::::VaultNotFound + ); + }); + } +} + +mod repay_edge_cases { + use super::*; + + /// **Test: Repaying zero amount succeeds as a no-op** + /// + /// Repaying 0 pUSD is allowed but has no effect. This triggers + /// fee updates without actually changing the debt. + #[test] + fn repay_zero_amount_succeeds_noop() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + let vault_before = VaultsStorage::::get(ALICE).unwrap(); + + // Repay 0 pUSD + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), 0)); + + let vault_after = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!( + vault_after.principal, vault_before.principal, + "Principal should be unchanged" + ); + }); + } + + /// **Test: Repay fails if user has insufficient pUSD balance** + /// + /// Users cannot repay debt if they don't have enough pUSD. + /// This tests the case where user spent/transferred their minted pUSD. + #[test] + fn repay_insufficient_pusd_balance_fails() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Transfer all pUSD to BOB (simulating spending) + assert_ok!(Assets::transfer( + RuntimeOrigin::signed(ALICE), + STABLECOIN_ASSET_ID, + BOB, + 200 * PUSD + )); + + // ALICE now has 0 pUSD but 200 pUSD debt + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, ALICE), 0); + + // Repay should fail - no pUSD to burn + assert_noop!( + Vaults::repay(RuntimeOrigin::signed(ALICE), 100 * PUSD), + TokenError::FundsUnavailable + ); + }); + } + + /// **Test: Repay with interest-first ordering (mint-on-accrual model)** + /// + /// The repay function pays in order: + /// 1. Accrued interest first (burned from user) + /// 2. Remaining amount goes to principal debt (burned) + /// + /// Note: With the mint-on-accrual model, interest is minted to the Insurance Fund + /// when fees accrue. On repay, both interest and principal are burned from the user. + /// The IF keeps the pUSD that was minted during accrual. + /// + /// Scenario: User repays 50 pUSD when interest is ~8 pUSD and debt is 200 pUSD. + /// Result: Interest is fully paid, remaining 42 pUSD reduces principal. + #[test] + fn repay_reduces_debt_and_transfers_interest() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Check IF balance before any interest accrues + let insurance_before_accrual = Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND); + + // Advance 1 year to accrue ~8 pUSD interest. + jump_to_block(5_256_000); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let interest = vault.accrued_interest; + assert!(interest > 0, "Should have accrued interest"); + + // Verify IF received interest during accrual (mint-on-accrual model) + let insurance_after_accrual = Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND); + assert_eq!( + insurance_after_accrual, + insurance_before_accrual + interest, + "InsuranceFund should receive interest on accrual" + ); + + // Partial interest-only repay: repay 1 pUSD (less than ~8 pUSD interest). + // Only interest should decrease; principal stays the same. + let partial = 1 * PUSD; + assert!(partial < interest); + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), partial)); + + let vault_partial = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!( + vault_partial.accrued_interest, + interest - partial, + "Interest should be partially reduced" + ); + assert_eq!(vault_partial.principal, 200 * PUSD, "Principal unchanged after partial"); + let remaining_interest = vault_partial.accrued_interest; + + // Repay 50 pUSD with interest-first ordering: + // - First remaining interest goes to interest (burned) + // - Remaining goes to principal (burned) + let repay_amount = 50 * PUSD; + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), repay_amount)); + + let vault_after = VaultsStorage::::get(ALICE).unwrap(); + + // Interest should be cleared (paid first) + assert_eq!(vault_after.accrued_interest, 0, "Interest should be cleared"); + + // Debt should be reduced by (repay_amount - remaining_interest) + let debt_paid = repay_amount - remaining_interest; + assert_eq!( + vault_after.principal, + 200 * PUSD - debt_paid, + "Principal should be reduced by remaining after interest" + ); + + // IF balance should not change on repay (interest was minted on accrual) + let insurance_after_repay = Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND); + assert_eq!( + insurance_after_repay, insurance_after_accrual, + "InsuranceFund balance unchanged on repay (already received on accrual)" + ); + }); + } + + /// **Test: Multiple repays progressively reduce debt** + /// + /// Sequential repayments with interest-first ordering: + /// - First repay: pays interest, then remaining to debt + /// - Subsequent repays: all goes to debt (no interest accrued in same block) + #[test] + fn repay_multiple_times_reduces_debt() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Advance 1 year to accrue ~8 pUSD interest. + jump_to_block(5_256_000); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let interest = vault.accrued_interest; + assert!(interest > 0, "Should have accrued interest"); + + // Give ALICE extra pUSD to cover multiple repayments + assert_ok!(Assets::mint_into(STABLECOIN_ASSET_ID, &ALICE, 100 * PUSD)); + + // First repay: 50 pUSD pays interest first, then debt + // - ~8 pUSD to interest + // - ~42 pUSD to debt + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), 50 * PUSD)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.accrued_interest, 0, "Interest should be cleared"); + let debt_after_first = 200 * PUSD - (50 * PUSD - interest); + assert_eq!( + vault.principal, debt_after_first, + "Debt reduced by remaining after interest" + ); + + // Second repay: all 50 pUSD goes to debt (no new interest since same block) + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), 50 * PUSD)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!( + vault.principal, + debt_after_first - 50 * PUSD, + "Principal should be reduced by 50" + ); + + // Third repay: all 50 pUSD goes to debt + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), 50 * PUSD)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!( + vault.principal, + debt_after_first - 100 * PUSD, + "Principal should be reduced by another 50" + ); + }); + } +} + +mod liquidation_additional { + use super::*; + + /// **Test: Owner can liquidate their own vault (self-liquidation)** + /// + /// There's no restriction preventing vault owners from triggering + /// liquidation on their own undercollateralized vault. + #[test] + fn self_liquidation_works() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Drop price to make vault undercollateralized + set_mock_price(Some(FixedU128::from_u32(3))); + + // ALICE liquidates her own vault + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(ALICE), ALICE)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::InLiquidation); + }); + } + + /// **Test: Accrued interest can push vault under liquidation threshold** + /// + /// A vault that was safe can become liquidatable if enough interest + /// accrues without the owner taking action. + #[test] + fn interest_accrual_can_trigger_liquidation() { + build_and_execute(|| { + // Set price to $2/DOT for easier math + set_mock_price(Some(FixedU128::from_u32(2))); + + let deposit = 100 * DOT; // $200 value + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Mint 100 pUSD (ratio = 200/100 = 200%, exactly at ICR) + // With interest, it will eventually drop below 180% MCR + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + // Verify initially safe + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultIsSafe + ); + + // Advance 5 years to accrue significant interest. + // 4% * 5 years * 100 = 20 pUSD interest + // Total obligation = 100 + 20 = 120 pUSD + // Ratio = 200 / 120 = 166.7% < 180% + jump_to_block(5_256_000 * 5); + + // Now liquidation should work. + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + }); + } + + /// **Test: Liquidation fails at zero price** + /// + /// A zero price indicates an oracle bug, not a valid market condition. + /// The system should treat zero price as invalid and reject the operation. + #[test] + fn liquidation_fails_at_zero_price() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Set price to zero. + set_mock_price(Some(FixedU128::zero())); + + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::PriceNotAvailable + ); + }); + } + + /// **Test: Vault is safe at very high price** + /// + /// When price increases significantly, an undercollateralized vault + /// becomes safe and cannot be liquidated. + #[test] + fn vault_safe_at_very_high_price() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // At $4.21: 100 DOT = $421, ratio = 421/200 = 210.5% + + // Set very high price: $100/DOT + set_mock_price(Some(FixedU128::from_u32(100))); + + // At $100: 100 DOT = $10,000, ratio = 10000/200 = 5000% + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultIsSafe + ); + }); + } +} + +mod collateral_manager { + use super::*; + + /// **Test: `execute_purchase` burns pUSD and transfers collateral** + /// + /// When an auction purchase is executed, the Vaults pallet: + /// 1. Burns pUSD from the buyer + /// 2. Releases collateral from Seized hold + /// 3. Transfers collateral to the recipient + #[test] + fn execute_purchase_burns_pusd_and_transfers_collateral() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Trigger liquidation to seize collateral + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Verify collateral is seized + let seized = Balances::balance_on_hold(&HoldReason::Seized.into(), &ALICE); + assert!(seized > 0, "Collateral should be seized"); + + // Give BOB pUSD to purchase + assert_ok!(Assets::mint_into(STABLECOIN_ASSET_ID, &BOB, 100 * PUSD)); + + let bob_pusd_before = Assets::balance(STABLECOIN_ASSET_ID, BOB); + let charlie_dot_before = Balances::free_balance(CHARLIE); + + // Execute purchase: BOB pays 50 pUSD for 20 DOT, sent to CHARLIE + // For testing, we treat the entire amount as principal (burned) + assert_ok!(Vaults::execute_purchase( + &BOB, + 20 * DOT, + PaymentBreakdown::new(50 * PUSD, 0, 0), + &CHARLIE, + &ALICE, + )); + + // BOB's pUSD burned (50 pUSD) + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, BOB), bob_pusd_before - 50 * PUSD); + + // CHARLIE received collateral (20 DOT) + assert_eq!(Balances::free_balance(CHARLIE), charlie_dot_before + 20 * DOT); + }); + } + + /// **Test: `execute_purchase` fails with insufficient pUSD** + /// + /// If the buyer doesn't have enough pUSD, the purchase fails. + #[test] + fn execute_purchase_fails_insufficient_pusd() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // BOB has no pUSD + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, BOB), 0); + + // Execute purchase should fail (BOB has no pUSD to burn) + assert_err!( + Vaults::execute_purchase( + &BOB, + 20 * DOT, + PaymentBreakdown::new(50 * PUSD, 0, 0), + &CHARLIE, + &ALICE, + ), + TokenError::FundsUnavailable + ); + }); + } + + /// **Test: `complete_auction` returns excess collateral to owner** + /// + /// When an auction completes with remaining collateral (debt fully satisfied), + /// the excess is returned to the vault owner. + #[test] + fn complete_auction_returns_excess_collateral() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + let alice_balance_before = Balances::free_balance(ALICE); + + // Simulate auction completion with 30 DOT remaining + let remaining_collateral = 30 * DOT; + assert_ok!(Vaults::complete_auction( + &ALICE, + remaining_collateral, + DebtComponents::default(), + &BOB, + 0 + )); + + // Vault should be immediately removed + assert!(VaultsStorage::::get(ALICE).is_none()); + + // ALICE should receive the remaining collateral + assert_eq!(Balances::free_balance(ALICE), alice_balance_before + remaining_collateral); + + // `VaultClosed` event should be emitted + System::assert_has_event(Event::::VaultClosed { owner: ALICE }.into()); + }); + } + + /// **Test: `complete_auction` records shortfall as bad debt** + /// + /// When an auction completes with remaining debt, the principal + interest + /// portion is recorded as bad debt. Unpaid penalty is NOT bad debt. + #[test] + fn complete_auction_records_shortfall_as_bad_debt() { + build_and_execute(|| { + let deposit = 100 * DOT; + let principal = 200 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), principal)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + let bad_debt_before = BadDebt::::get(); + + // Simulate auction completion with remaining debt breakdown + let remaining_debt = + DebtComponents { principal: 80 * PUSD, interest: 20 * PUSD, penalty: 10 * PUSD }; + assert_ok!(Vaults::complete_auction(&ALICE, 0, remaining_debt, &BOB, 0)); + + // Bad debt = principal + interest only (not penalty) + let expected_bad_debt = 100 * PUSD; // 80 + 20 + assert_eq!(BadDebt::::get(), bad_debt_before + expected_bad_debt); + + // AuctionShortfall event uses total remaining debt + System::assert_has_event( + Event::::AuctionShortfall { shortfall: remaining_debt.total() }.into(), + ); + System::assert_has_event( + Event::::BadDebtAccrued { owner: ALICE, amount: expected_bad_debt }.into(), + ); + }); + } + + #[test] + fn complete_auction_finalizes_when_keeper_incentive_payment_fails() { + build_and_execute(|| { + let deposit = 100 * DOT; + let principal = 200 * PUSD; + let keeper_incentive = 10 * PUSD; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), principal)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, INSURANCE_FUND), 0); + + let penalty = LiquidationPenalty::::get().unwrap().mul_floor(principal); + let total_debt = principal + penalty; + + // remaining_debt with principal shortfall + let remaining_debt = + DebtComponents { principal: 100 * PUSD, interest: 0, penalty: 10 * PUSD }; + assert_ok!( + Vaults::complete_auction(&ALICE, 0, remaining_debt, &BOB, keeper_incentive,) + ); + + assert!(VaultsStorage::::get(ALICE).is_none()); + // Bad debt = principal + interest = 100 pUSD + assert_eq!(BadDebt::::get(), 100 * PUSD); + // CLA = total_debt - remaining_debt.total() = 226 - 110 = 116 + assert_eq!( + CurrentLiquidationAmount::::get(), + total_debt - remaining_debt.total() + ); + assert_eq!(Assets::balance(STABLECOIN_ASSET_ID, BOB), 0); + System::assert_has_event( + Event::::KeeperIncentivePaymentFailed { + keeper: BOB, + amount: keeper_incentive, + } + .into(), + ); + }); + } + + /// **Test: `get_dot_price` returns oracle price for the collateral asset** + /// + /// The `CollateralManager` trait exposes the oracle price to the Auctions pallet. + /// Note: The mock oracle normalizes prices (raw_price * 10^6 / 10^10). + #[test] + fn get_dot_price_returns_oracle_price() { + build_and_execute(|| { + // Default raw price is 4.21 USD/DOT + // Normalized: 4.21 * 10^6 / 10^10 = 0.000421 + let price = Vaults::get_dot_price(); + assert!(price.is_some(), "Price should be available"); + let expected_normalized = FixedU128::from_rational(421, 100) + .saturating_mul(FixedU128::saturating_from_integer(1_000_000u128)) + .checked_div(&FixedU128::saturating_from_integer(10_000_000_000u128)) + .unwrap(); + assert_eq!(price, Some(expected_normalized)); + + // Change price to $5/DOT + set_mock_price(Some(FixedU128::from_u32(5))); + let price = Vaults::get_dot_price(); + let expected_normalized = FixedU128::from_u32(5) + .saturating_mul(FixedU128::saturating_from_integer(1_000_000u128)) + .checked_div(&FixedU128::saturating_from_integer(10_000_000_000u128)) + .unwrap(); + assert_eq!(price, Some(expected_normalized)); + + // Remove price + set_mock_price(None); + let price = Vaults::get_dot_price(); + assert_eq!(price, None); + }); + } +} + +mod lifecycle_integration { + use super::*; + + /// **Test: Full vault lifecycle from creation to closure** + /// + /// Tests the complete happy path: create → deposit → mint → repay → close. + #[test] + fn full_lifecycle_create_mint_repay_close() { + build_and_execute(|| { + let initial_deposit = 100 * DOT; + let additional_deposit = 50 * DOT; + let total_collateral = initial_deposit + additional_deposit; + + // Record starting balance + let alice_balance_before = Balances::free_balance(ALICE); + + // 1. Create vault + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), initial_deposit)); + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::Healthy); + assert_eq!(vault.principal, 0); + + // 2. Mint pUSD + let mint_amount = 200 * PUSD; + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), mint_amount)); + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, mint_amount); + + // 3. Add more collateral + assert_ok!(Vaults::deposit_collateral( + RuntimeOrigin::signed(ALICE), + additional_deposit + )); + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.get_held_collateral(&ALICE), total_collateral); + + // 4. Advance time to accrue interest. + jump_to_block(2_628_000); // ~6 months, ~4 pUSD interest + + // 5. Repay all debt + interest + let vault = VaultsStorage::::get(ALICE).unwrap(); + let interest = vault.accrued_interest; + + // Give ALICE enough to cover interest + if interest > 0 { + assert_ok!(Assets::mint_into(STABLECOIN_ASSET_ID, &ALICE, interest)); + } + + // Repay everything + assert_ok!(Vaults::repay(RuntimeOrigin::signed(ALICE), mint_amount + interest)); + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 0); + assert_eq!(vault.accrued_interest, 0); + + // 6. Close vault + assert_ok!(Vaults::close_vault(RuntimeOrigin::signed(ALICE))); + assert!(VaultsStorage::::get(ALICE).is_none()); + + // Verify all collateral returned - ALICE's balance should be restored + let alice_balance_after = Balances::free_balance(ALICE); + assert_eq!( + alice_balance_after, alice_balance_before, + "All collateral should be returned after closing vault" + ); + + // Verify ALICE has no remaining pUSD (all burned during repay) + assert_eq!( + Assets::balance(STABLECOIN_ASSET_ID, ALICE), + 0, + "All pUSD should be burned after full repayment" + ); + }); + } + + /// **Test: Vault becomes safe after price increase, can mint more** + /// + /// When price increases, the collateralization ratio improves, + /// allowing the user to mint additional pUSD. + #[test] + fn vault_becomes_safe_after_price_increase_can_mint_more() { + build_and_execute(|| { + // Start at $4.21/DOT + let deposit = 100 * DOT; // $421 value + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + + // Mint near max: 200 pUSD (ratio = 421/200 = 210.5%) + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Cannot mint more (would breach 200%) + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 15 * PUSD), + Error::::UnsafeCollateralizationRatio + ); + + // Price increases to $10/DOT + set_mock_price(Some(FixedU128::from_u32(10))); + + // Now: 100 DOT = $1000, ratio = 1000/200 = 500% + // Max mint at 200%: 1000/2.0 = 500 pUSD + // Available: 500 - 200 = 300 pUSD + + // Can now mint more + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 280 * PUSD)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, 480 * PUSD); + }); + } + + /// **Test: Interest accrues correctly over multiple time periods** + /// + /// Interest calculation is consistent across multiple time periods. + #[test] + fn interest_accrues_over_multiple_operations() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Advance 3 months. + jump_to_block(1_314_000); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let interest_q1 = vault.accrued_interest; + assert!(interest_q1 > 0, "Should accrue interest in Q1"); + + // Advance another 3 months (to 6 months total) + jump_to_block(2_628_000); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let interest_q2 = vault.accrued_interest; + assert!(interest_q2 > interest_q1, "Should accrue more interest in Q2"); + + // Advance another 6 months (to 1 year total) + jump_to_block(5_256_000); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + let interest_full_year = vault.accrued_interest; + + // 4% of 200 pUSD for 1 year = 8 pUSD + let expected = 8 * PUSD; + assert_approx_eq( + interest_full_year, + expected, + INTEREST_TOLERANCE, + "Interest after full year", + ); + }); + } +} + +mod overflow_protection { + use super::*; + + /// **Test: Very large debt amounts don't overflow** + /// + /// The system correctly handles very large debt values without + /// arithmetic overflow in ratio calculations. + #[test] + fn very_large_debt_does_not_overflow() { + build_and_execute(|| { + // Set very high max debt, position, and liquidation limits + MaximumIssuance::::put(u128::MAX / 2); + MaxPositionAmount::::put(u128::MAX / 2); + MaxLiquidationAmount::::put(u128::MAX / 2); + + // Give ALICE a lot of DOT + let huge_deposit = 1_000_000_000 * DOT; // 1 billion DOT + let _ = Balances::mint_into(&ALICE, huge_deposit); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), huge_deposit)); + + // Mint a large amount (but within ratio) + // At $4.21/DOT: 1B DOT = $4.21B, max mint = $2.8B pUSD + let large_mint = 2_000_000_000 * PUSD; // 2 billion pUSD + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), large_mint)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.principal, large_mint); + }); + } + + /// **Test: Very large collateral amounts don't overflow** + /// + /// The system handles very large collateral values without overflow + /// in hold operations and ratio calculations. + #[test] + fn very_large_collateral_does_not_overflow() { + build_and_execute(|| { + // Give ALICE maximum DOT + let huge_deposit = u128::MAX / 4; + let _ = Balances::mint_into(&ALICE, huge_deposit); + + // Create vault with huge deposit + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), huge_deposit)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.get_held_collateral(&ALICE), huge_deposit); + }); + } +} + +mod on_idle_edge_cases { + use super::*; + + /// **Test: `on_idle` with no vaults uses minimal weight** + /// + /// When there are no vaults to process, `on_idle` should return + /// quickly with minimal weight consumption. + #[test] + fn on_idle_with_no_vaults_uses_minimal_weight() { + build_and_execute(|| { + // No vaults created + assert!(VaultsStorage::::iter().next().is_none()); + + let weight = Vaults::on_idle(1, Weight::MAX); + + // Should consume minimum weight for reading cursor and config + // (on_idle_base_weight = reads_writes(2, 1) for cursor and stale threshold) + assert_eq!(weight, Vaults::on_idle_base_weight(), "No vaults = only base weight"); + }); + } + + /// **Test: `on_idle` updates stale vault fees** + /// + /// Vaults that haven't been touched for `StaleVaultThreshold` + /// get their fees updated during `on_idle`. + #[test] + fn on_idle_updates_stale_vault_fees() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + let vault_before = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault_before.accrued_interest, 0); + + // Advance past StaleVaultThreshold (14_400_000 ms = 4 hours) plus enough + // time to accrue meaningful interest + jump_to_block(5_256_000); // 1 year worth of blocks. + + // Run on_idle (already called by jump_to_block, but call again to verify) + let weight = Vaults::on_idle(5_256_000, Weight::MAX); + assert!(weight.ref_time() > 0, "Should have done work"); + + // Vault should have updated fees + let vault_after = VaultsStorage::::get(ALICE).unwrap(); + assert!(vault_after.accrued_interest > 0, "on_idle should update stale vault fees"); + assert!( + vault_after.last_fee_update > vault_before.last_fee_update, + "Fee timestamp should be updated" + ); + }); + } + + /// **Test: `on_idle` skips healthy non-stale vaults** + /// + /// Vaults that have been recently touched (within `StaleVaultThreshold`) + /// are not updated by `on_idle`. + #[test] + fn on_idle_skips_healthy_non_stale_vaults() { + build_and_execute(|| { + let deposit = 100 * DOT; + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), deposit)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Touch the vault + run_to_block(100); + assert_ok!(Vaults::deposit_collateral(RuntimeOrigin::signed(ALICE), 0)); + + let vault_before = VaultsStorage::::get(ALICE).unwrap(); + + // Advance less than StaleVaultThreshold (14_400_000 ms = 4 hours) + // 1900 blocks = 11,400,000 ms < 14,400,000 ms threshold + run_to_block(2000); + + // Run on_idle + let _weight = Vaults::on_idle(2000, Weight::MAX); + + // Should not have updated the vault (not stale yet) + let vault_after = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!( + vault_after.last_fee_update, vault_before.last_fee_update, + "Non-stale vault should not be updated" + ); + }); + } + + /// **Test: `on_idle` processes multiple vault types correctly** + /// + /// When there are `Healthy` and `InLiquidation` vaults, + /// `on_idle` handles each appropriately. + #[test] + fn on_idle_handles_mixed_vault_states() { + build_and_execute(|| { + // Create ALICE's vault - will be `Healthy` + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + // Create BOB's vault - will be `InLiquidation` + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(BOB), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(BOB), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(CHARLIE), BOB)); + + assert_ok!(Vaults::complete_auction(&BOB, 0, DebtComponents::default(), &ALICE, 0)); + + // BOB's vault should be immediately removed after auction completion + assert!(VaultsStorage::::get(BOB).is_none()); + + // Reset price + set_mock_price(Some(FixedU128::from_rational(421, 100))); + + // Create CHARLIE's vault - will be `InLiquidation` + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(CHARLIE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(CHARLIE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(ALICE), CHARLIE)); + + // Verify states - only ALICE (`Healthy`) and CHARLIE (`InLiquidation`) + assert_eq!(VaultsStorage::::get(ALICE).unwrap().status, VaultStatus::Healthy); + assert_eq!( + VaultsStorage::::get(CHARLIE).unwrap().status, + VaultStatus::InLiquidation + ); + + // Run `on_idle` - no cleanup needed, only stale fee updates + Vaults::on_idle(1, Weight::MAX); + + // ALICE's `Healthy` vault should remain + assert!(VaultsStorage::::get(ALICE).is_some()); + + // CHARLIE's `InLiquidation` vault should remain (auction in progress) + assert!(VaultsStorage::::get(CHARLIE).is_some()); + }); + } +} + +mod parameter_edge_cases { + use super::*; + + /// **Test: Setting initial ratio below minimum ratio fails** + /// + /// Initial ratio must be >= minimum ratio to allow borrowing. + /// Setting initial_ratio < min_ratio would make all loans impossible. + #[test] + fn set_initial_ratio_below_minimum_fails() { + build_and_execute(|| { + // Set minimum to 160% first + assert_ok!(Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::root(), + ratio(160) + )); + + // Try to set initial to 140% (below 160% minimum) - should fail + assert_noop!( + Vaults::set_initial_collateralization_ratio(RuntimeOrigin::root(), ratio(140)), + Error::::InitialRatioMustExceedMinimum + ); + + // Initial ratio should remain at original value (200%) + assert_eq!(InitialCollateralizationRatio::::get(), Some(ratio(200))); + }); + } + + /// **Test: Setting minimum ratio above initial ratio fails** + /// + /// Minimum ratio cannot exceed initial ratio, as this would allow + /// immediate liquidation after minting (mint at initial CR, liquidate at min CR). + #[test] + fn set_minimum_ratio_above_initial_fails() { + build_and_execute(|| { + // Initial ratio is 200% (genesis default) + assert_eq!(InitialCollateralizationRatio::::get(), Some(ratio(200))); + + // Try to set minimum to 220% (above 200% initial) - should fail + assert_noop!( + Vaults::set_minimum_collateralization_ratio(RuntimeOrigin::root(), ratio(220)), + Error::::InitialRatioMustExceedMinimum + ); + + // Minimum ratio should remain at original value (180%) + assert_eq!(MinimumCollateralizationRatio::::get(), Some(ratio(180))); + }); + } + + /// **Test: Setting liquidation penalty to zero works** + /// + /// Governance can set liquidation penalty to 0%, removing the + /// financial penalty for being liquidated. + #[test] + fn set_liquidation_penalty_to_zero() { + build_and_execute(|| { + assert_ok!(Vaults::set_liquidation_penalty(RuntimeOrigin::root(), Permill::zero())); + + assert_eq!(LiquidationPenalty::::get(), Some(Permill::zero())); + + // Create and liquidate a vault to verify it works + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + set_mock_price(Some(FixedU128::from_u32(3))); + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + // Liquidation should work with 0% penalty + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::InLiquidation); + }); + } + + /// **Test: Setting stability fee to zero stops interest accrual** + /// + /// With 0% stability fee, vaults should not accrue any interest + /// over time. + #[test] + fn set_stability_fee_to_zero() { + build_and_execute(|| { + assert_ok!(Vaults::set_stability_fee(RuntimeOrigin::root(), Permill::zero())); + + assert_eq!(StabilityFee::::get(), Some(Permill::zero())); + + // Create vault with debt + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Advance significant time + jump_to_block(5_256_000); // 1 year + + // Trigger fee update + assert_ok!(Vaults::poke(RuntimeOrigin::signed(BOB), ALICE)); + + // No interest should have accrued + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.accrued_interest, 0, "Zero fee = zero interest"); + assert_eq!( + vault.last_fee_update, + MockTimestamp::get(), + "Zero-fee vaults should still refresh their fee timestamp" + ); + }); + } + + /// **Test: Setting max debt to zero blocks all minting** + /// + /// With `MaximumIssuance` set to 0, no new pUSD can be minted by anyone. + #[test] + fn set_max_debt_to_zero_blocks_all_minting() { + build_and_execute(|| { + // First create a vault with some debt + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + // Set max debt to 0 + MaximumIssuance::::put(0); + + // BOB creates a new vault + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(BOB), 100 * DOT)); + + // BOB cannot mint any pUSD above the minimum mint amount + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(BOB), 5 * PUSD), + Error::::ExceedsMaxDebt + ); + + // ALICE also cannot mint more + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 5 * PUSD), + Error::::ExceedsMaxDebt + ); + }); + } + + /// **Test: Setting minimum CR to 100% or below fails** + #[test] + fn set_minimum_ratio_at_or_below_100_percent_fails() { + build_and_execute(|| { + // Exactly 100% should fail + assert_noop!( + Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::root(), + FixedU128::one() + ), + Error::::MinimumRatioTooLow + ); + + // Below 100% should fail + assert_noop!( + Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::root(), + FixedU128::from_rational(99, 100) + ), + Error::::MinimumRatioTooLow + ); + + // Zero should fail + assert_noop!( + Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::root(), + FixedU128::zero() + ), + Error::::MinimumRatioTooLow + ); + + // Just above 100% should succeed (if below initial ratio) + assert_ok!(Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::root(), + FixedU128::from_rational(101, 100) + )); + }); + } + + /// **Test: MaxLiquidationAmount cannot be set below MaxPositionAmount** + #[test] + fn set_max_liquidation_below_max_position_fails() { + build_and_execute(|| { + // Genesis: MaxPositionAmount = 10M, MaxLiquidationAmount = 20M + let max_position = MaxPositionAmount::::get().unwrap(); + assert!(max_position > 0); + + // Try to set MaxLiquidationAmount below MaxPositionAmount + assert_noop!( + Vaults::set_max_liquidation_amount(RuntimeOrigin::root(), max_position - 1), + Error::::MaxLiquidationBelowMaxPosition + ); + + // Setting equal to MaxPositionAmount should succeed + assert_ok!(Vaults::set_max_liquidation_amount(RuntimeOrigin::root(), max_position)); + }); + } + + /// **Test: MaxPositionAmount cannot be set above MaxLiquidationAmount** + #[test] + fn set_max_position_above_max_liquidation_fails() { + build_and_execute(|| { + // Genesis: MaxPositionAmount = 10M, MaxLiquidationAmount = 20M + let max_liq = MaxLiquidationAmount::::get().unwrap(); + + // Try to set MaxPositionAmount above MaxLiquidationAmount + assert_noop!( + Vaults::set_max_position_amount(RuntimeOrigin::root(), max_liq + 1), + Error::::MaxLiquidationBelowMaxPosition + ); + + // Setting equal to MaxLiquidationAmount should succeed + assert_ok!(Vaults::set_max_position_amount(RuntimeOrigin::root(), max_liq)); + }); + } + + /// **Test: Raising MCR makes previously safe vaults liquidatable** + /// + /// When governance raises MinimumCollateralizationRatio, vaults that were safe + /// under the old threshold may become undercollateralized and eligible for + /// liquidation under the new threshold. + #[test] + fn raising_mcr_makes_safe_vault_liquidatable() { + build_and_execute(|| { + // Create vault: 100 DOT at $4.21 = $421 collateral + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + // Mint 200 pUSD → CR = 421/200 = 210.5% + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Vault is safe under current MCR of 180% + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::VaultIsSafe + ); + + // Governance raises initial ratio first (must be >= new MCR) + assert_ok!(Vaults::set_initial_collateralization_ratio( + RuntimeOrigin::root(), + FixedU128::from_rational(250, 100) // 250% + )); + + // Now raise MCR from 180% to 220% + // ALICE's CR of 210.5% is now below the new 220% threshold + assert_ok!(Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::root(), + FixedU128::from_rational(220, 100) + )); + + // ALICE's vault is now undercollateralized and can be liquidated + assert_ok!(Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE)); + + let vault = VaultsStorage::::get(ALICE).unwrap(); + assert_eq!(vault.status, VaultStatus::InLiquidation); + }); + } +} + +mod oracle_staleness { + use super::*; + + /// **Test: Mint fails when oracle is stale** + /// + /// When the oracle price timestamp is older than the staleness threshold, + /// minting should fail with `OracleStale` error. + #[test] + fn mint_fails_when_oracle_is_stale() { + build_and_execute(|| { + // Create vault with collateral + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + // Make oracle stale by setting timestamp to 2x threshold ago + let stale_timestamp = MockTimestamp::get().saturating_sub( + 2 * OracleStalenessThreshold::::get().expect("set in genesis; qed"), + ); + set_mock_price_timestamp(stale_timestamp); + + // Mint should fail with OracleStale + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 10 * PUSD), + Error::::OracleStale + ); + }); + } + + /// **Test: Withdraw with debt fails when oracle is stale** + /// + /// When the vault has debt, withdrawing collateral requires a price check. + /// This should fail if the oracle is stale. + #[test] + fn withdraw_with_debt_fails_when_oracle_stale() { + build_and_execute(|| { + // Setup vault with debt (use 200 DOT to allow partial withdrawal) + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 200 * DOT)); + set_mock_price_timestamp(MockTimestamp::get()); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 10 * PUSD)); + + // Make oracle stale + let stale_timestamp = MockTimestamp::get().saturating_sub( + 2 * OracleStalenessThreshold::::get().expect("set in genesis; qed"), + ); + set_mock_price_timestamp(stale_timestamp); + + // Withdraw should fail due to stale oracle (remaining 190 DOT > min deposit) + assert_noop!( + Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 10 * DOT), + Error::::OracleStale + ); + }); + } + + /// **Test: Withdraw without debt succeeds even when oracle is stale** + /// + /// When the vault has no debt, withdrawing collateral doesn't need a price check, + /// so it should succeed even with a stale oracle. + #[test] + fn withdraw_without_debt_succeeds_even_when_oracle_stale() { + build_and_execute(|| { + // Vault with no debt + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 200 * DOT)); + + // Make oracle stale + let stale_timestamp = MockTimestamp::get().saturating_sub( + 2 * OracleStalenessThreshold::::get().expect("set in genesis; qed"), + ); + set_mock_price_timestamp(stale_timestamp); + + // Withdraw should succeed (no debt, no price check needed) + assert_ok!(Vaults::withdraw_collateral(RuntimeOrigin::signed(ALICE), 50 * DOT)); + }); + } + + /// **Test: Liquidation fails when oracle is stale** + /// + /// Liquidating a vault requires checking the collateralization ratio, + /// which requires a fresh price. + #[test] + fn liquidate_fails_when_oracle_stale() { + build_and_execute(|| { + // Set a specific price for predictable ratio calculation + set_mock_price(Some(FixedU128::from_u32(2))); // $2/DOT + + // Setup vault that will be undercollateralized + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + set_mock_price_timestamp(MockTimestamp::get()); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); // 200% ratio + + // Drop price to make vault undercollateralized + set_mock_price(Some(FixedU128::from_u32(1))); // $1/DOT -> 100% ratio < 180% MCR + + // Make oracle stale + let stale_timestamp = MockTimestamp::get().saturating_sub( + 2 * OracleStalenessThreshold::::get().expect("set in genesis; qed"), + ); + set_mock_price_timestamp(stale_timestamp); + + // Liquidation should fail + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::OracleStale + ); + }); + } + + /// **Test: Operations auto-resume when oracle becomes fresh** + /// + /// After the oracle was stale and operations failed, they should + /// succeed again once a fresh price is available. + #[test] + fn operations_auto_resume_when_oracle_becomes_fresh() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + // Oracle stale - mint fails + let stale_timestamp = MockTimestamp::get().saturating_sub( + 2 * OracleStalenessThreshold::::get().expect("set in genesis; qed"), + ); + set_mock_price_timestamp(stale_timestamp); + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 10 * PUSD), + Error::::OracleStale + ); + + // Oracle fresh again - mint succeeds + set_mock_price_timestamp(MockTimestamp::get()); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 10 * PUSD)); + }); + } + + /// **Test: Oracle at exact threshold boundary is considered fresh** + /// + /// A price that is exactly at the staleness threshold should still + /// be considered valid. + #[test] + fn oracle_at_exact_threshold_is_fresh() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + // Set timestamp to exactly at threshold boundary + let at_threshold = MockTimestamp::get().saturating_sub( + OracleStalenessThreshold::::get().expect("set in genesis; qed"), + ); + set_mock_price_timestamp(at_threshold); + + // Should still succeed (at boundary, not past it) + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 10 * PUSD)); + }); + } +} + +mod parameter_invariants { + use super::*; + + /// **Mathematical invariant test: Liquidation penalty vs keeper incentive** + /// + /// This test verifies a mathematical property of the protocol parameters. + /// It uses assumed keeper incentive values (tip=1 pUSD, chip=0.1%) that should + /// match the auctions pallet configuration. + /// + /// Keeper incentive formula (from auctions pallet): + /// keeper_incentive = tip + (chip × tab) + /// + /// Where: + /// - tip = flat fee (default: 1 pUSD) + /// - chip = percentage of tab (default: 0.1%) + /// - tab = principal + interest + penalty + /// + /// The auction pallet caps keeper_incentive to penalty, but this test + /// verifies that with reasonable parameters, the penalty naturally exceeds + /// the keeper incentive for typical vault sizes. + #[test] + fn liquidation_penalty_exceeds_keeper_incentive() { + build_and_execute(|| { + // Keeper incentive parameters. + let tip = 1 * PUSD; // 1 pUSD flat fee + let chip = Permill::from_parts(1000); // 0.1% + + // Get liquidation penalty from storage + let liquidation_penalty = + LiquidationPenalty::::get().expect("set in genesis; qed"); + + // Test various vault sizes to ensure the invariant holds + let test_principals = [ + 100 * PUSD, // Small vault + 1_000 * PUSD, // Medium vault + 10_000 * PUSD, // Large vault + 100_000 * PUSD, // Very large vault + 1_000_000 * PUSD, // Massive vault + ]; + + for principal in test_principals { + // Assume 10% interest for a stressed scenario + let interest = principal / 10; + + // Calculate penalty + let penalty = liquidation_penalty.mul_floor(principal); + + // Calculate tab (total debt for auction) + let tab = principal + interest + penalty; + + // Calculate keeper incentive (before capping) + let keeper_incentive_raw = tip + chip.mul_floor(tab); + + // The penalty should exceed the raw keeper incentive + // (the auction pallet will cap it anyway, but we want margin) + assert!( + penalty > keeper_incentive_raw, + "Penalty ({} pUSD) should exceed keeper incentive ({} pUSD) for principal {} pUSD. \ + This ensures keeper is paid only from penalty, not from principal/interest.", + penalty / PUSD, + keeper_incentive_raw / PUSD, + principal / PUSD + ); + + // Log the margin for visibility + let margin = penalty.saturating_sub(keeper_incentive_raw); + let margin_percent = if penalty > 0 { (margin * 100) / penalty } else { 0 }; + + // Ensure there's meaningful margin (at least 50% of penalty remains for IF) + assert!( + margin_percent >= 50, + "At least 50% of penalty should remain for Insurance Fund after keeper. \ + Got {}% for principal {} pUSD", + margin_percent, + principal / PUSD + ); + } + }); + } + + /// **Mathematical invariant test: Penalty vs keeper incentive at minimum vault** + /// + /// This test documents an expected edge case: at the minimum vault size (5 pUSD), + /// the penalty (0.65 pUSD) is LESS than the keeper incentive (~1.006 pUSD). + /// This is expected because the flat tip (1 pUSD) dominates for tiny vaults. + /// + /// The auction pallet MUST cap `keeper_incentive` to `penalty` to handle this case. + #[test] + fn penalty_exceeds_keeper_at_minimum_vault() { + build_and_execute(|| { + let tip = 1 * PUSD; + let chip = Permill::from_parts(1000); // 0.1% + let liquidation_penalty = + LiquidationPenalty::::get().expect("set in genesis; qed"); // 13% + + // Minimum principal (5 pUSD from MinimumMint) + let principal = 5 * PUSD; + let interest = 0; // Fresh vault, no interest + + let penalty = liquidation_penalty.mul_floor(principal); + let tab = principal + interest + penalty; + let keeper_incentive = tip + chip.mul_floor(tab); + + // For 5 pUSD principal with 13% penalty: + // penalty = 0.65 pUSD + // tab = 5 + 0 + 0.65 = 5.65 pUSD + // keeper = 1 + 0.001 * 5.65 = 1.00565 pUSD + // + // At minimum vault size, penalty < keeper because tip dominates. + // This documents that the auction pallet's capping mechanism is essential. + assert!( + penalty < keeper_incentive, + "At minimum vault (5 pUSD), penalty ({}) should be less than uncapped keeper \ + incentive ({}). This edge case requires auction pallet capping.", + penalty, + keeper_incentive + ); + + // Verify the penalty is indeed less than the tip + assert!( + penalty < tip, + "For tiny vaults, penalty ({}) should be less than tip ({})", + penalty, + tip + ); + }); + } + + /// **Test: Minimum principal where penalty exceeds keeper incentive** + /// + /// Math test to find the crossover point where liquidation penalty exceeds keeper + /// incentive. Documents the relationship between MinimumMint and this crossover. + /// + /// Vaults below ~8 pUSD principal have penalty < keeper_incentive, meaning they + /// rely on the auction pallet's capping mechanism. Since MinimumMint (5 pUSD) is + /// below this crossover, this test verifies that relationship is understood. + #[test] + fn find_minimum_principal_for_penalty_dominance() { + build_and_execute(|| { + let tip = 1 * PUSD; + let chip = Permill::from_parts(1000); // 0.1% + let liquidation_penalty = + LiquidationPenalty::::get().expect("set in genesis; qed"); // 13% + + // Solve for principal where penalty = keeper_incentive: + // penalty = principal * 0.13 + // keeper = tip + chip * (principal + penalty) + // keeper = tip + chip * principal * (1 + 0.13) + // keeper = tip + chip * principal * 1.13 + // + // Set penalty = keeper: + // principal * 0.13 = tip + chip * principal * 1.13 + // principal * (0.13 - chip * 1.13) = tip + // principal = tip / (0.13 - 0.001 * 1.13) + // principal = tip / (0.13 - 0.00113) + // principal = tip / 0.12887 + // principal ≈ 7.76 pUSD + + let mut min_principal = 0u128; + for principal in (1..100).map(|x| x * PUSD) { + let penalty = liquidation_penalty.mul_floor(principal); + let tab = principal + penalty; // No interest for simplicity + let keeper = tip + chip.mul_floor(tab); + + if penalty > keeper { + min_principal = principal; + break; + } + } + + // The minimum principal should be around 8 pUSD + // (above our MinimumMint of 5 pUSD, so small vaults rely on capping) + assert!( + min_principal > 0 && min_principal <= 10 * PUSD, + "Expected minimum principal around 8 pUSD, got {} pUSD", + min_principal / PUSD + ); + + // Verify that MinimumMint is below the crossover point - documenting the + // dependency on auction pallet capping for small vaults + let min_mint = 5 * PUSD; + assert!( + min_mint < min_principal, + "MinimumMint ({} pUSD) should be below crossover ({} pUSD), \ + meaning small vaults rely on auction pallet capping", + min_mint / PUSD, + min_principal / PUSD + ); + }); + } +} + +mod governance_new_params { + use super::*; + + /// **Test: Governance can update minimum deposit amount** + #[test] + fn set_minimum_deposit_works() { + build_and_execute(|| { + let old_value = MinimumDeposit::::get().expect("set in genesis; qed"); + let new_value = 200 * DOT; + + assert_ok!(Vaults::set_minimum_deposit(RuntimeOrigin::root(), new_value)); + + assert_eq!(MinimumDeposit::::get(), Some(new_value)); + System::assert_has_event( + Event::::MinimumDepositUpdated { old_value, new_value }.into(), + ); + }); + } + + #[test] + fn set_minimum_deposit_rejects_zero() { + build_and_execute(|| { + assert_noop!( + Vaults::set_minimum_deposit(RuntimeOrigin::root(), 0), + Error::::ZeroValueNotAllowed + ); + }); + } + + /// **Test: Emergency privilege cannot set minimum deposit** + #[test] + fn set_minimum_deposit_requires_full_privilege() { + build_and_execute(|| { + assert_noop!( + Vaults::set_minimum_deposit(RuntimeOrigin::signed(EMERGENCY_ADMIN), 200 * DOT), + Error::::InsufficientPrivilege + ); + }); + } + + /// **Test: Regular user cannot set minimum deposit** + #[test] + fn set_minimum_deposit_fails_for_non_manager() { + build_and_execute(|| { + assert_noop!( + Vaults::set_minimum_deposit(RuntimeOrigin::signed(ALICE), 200 * DOT), + frame_support::error::BadOrigin + ); + }); + } + + /// **Test: Governance can update minimum mint amount** + #[test] + fn set_minimum_mint_works() { + build_and_execute(|| { + let old_value = MinimumMint::::get().expect("set in genesis; qed"); + let new_value = 10 * PUSD; + + assert_ok!(Vaults::set_minimum_mint(RuntimeOrigin::root(), new_value)); + + assert_eq!(MinimumMint::::get(), Some(new_value)); + System::assert_has_event( + Event::::MinimumMintUpdated { old_value, new_value }.into(), + ); + }); + } + + #[test] + fn set_minimum_mint_rejects_zero() { + build_and_execute(|| { + assert_noop!( + Vaults::set_minimum_mint(RuntimeOrigin::root(), 0), + Error::::ZeroValueNotAllowed + ); + }); + } + + /// **Test: Emergency privilege cannot set minimum mint** + #[test] + fn set_minimum_mint_requires_full_privilege() { + build_and_execute(|| { + assert_noop!( + Vaults::set_minimum_mint(RuntimeOrigin::signed(EMERGENCY_ADMIN), 10 * PUSD), + Error::::InsufficientPrivilege + ); + }); + } + + /// **Test: Regular user cannot set minimum mint** + #[test] + fn set_minimum_mint_fails_for_non_manager() { + build_and_execute(|| { + assert_noop!( + Vaults::set_minimum_mint(RuntimeOrigin::signed(ALICE), 10 * PUSD), + frame_support::error::BadOrigin + ); + }); + } + + /// **Test: Governance can update stale vault threshold** + #[test] + fn set_stale_vault_threshold_works() { + build_and_execute(|| { + let old_value = StaleVaultThreshold::::get().expect("set in genesis; qed"); + let new_value = 28_800_000u64; // 8 hours + + assert_ok!(Vaults::set_stale_vault_threshold(RuntimeOrigin::root(), new_value)); + + assert_eq!(StaleVaultThreshold::::get(), Some(new_value)); + System::assert_has_event( + Event::::StaleVaultThresholdUpdated { old_value, new_value }.into(), + ); + }); + } + + #[test] + fn set_stale_vault_threshold_rejects_zero() { + build_and_execute(|| { + assert_noop!( + Vaults::set_stale_vault_threshold(RuntimeOrigin::root(), 0), + Error::::ZeroValueNotAllowed + ); + }); + } + + /// **Test: Emergency privilege cannot set stale vault threshold** + #[test] + fn set_stale_vault_threshold_requires_full_privilege() { + build_and_execute(|| { + assert_noop!( + Vaults::set_stale_vault_threshold( + RuntimeOrigin::signed(EMERGENCY_ADMIN), + 28_800_000 + ), + Error::::InsufficientPrivilege + ); + }); + } + + /// **Test: Regular user cannot set stale vault threshold** + #[test] + fn set_stale_vault_threshold_fails_for_non_manager() { + build_and_execute(|| { + assert_noop!( + Vaults::set_stale_vault_threshold(RuntimeOrigin::signed(ALICE), 28_800_000), + frame_support::error::BadOrigin + ); + }); + } + + /// **Test: Governance can update oracle staleness threshold** + #[test] + fn set_oracle_staleness_threshold_works() { + build_and_execute(|| { + let old_value = OracleStalenessThreshold::::get().expect("set in genesis; qed"); + let new_value = 7_200_000u64; // 2 hours + + assert_ok!(Vaults::set_oracle_staleness_threshold(RuntimeOrigin::root(), new_value)); + + assert_eq!(OracleStalenessThreshold::::get(), Some(new_value)); + System::assert_has_event( + Event::::OracleStalenessThresholdUpdated { old_value, new_value }.into(), + ); + }); + } + + #[test] + fn set_oracle_staleness_threshold_rejects_zero() { + build_and_execute(|| { + assert_noop!( + Vaults::set_oracle_staleness_threshold(RuntimeOrigin::root(), 0), + Error::::ZeroValueNotAllowed + ); + }); + } + + /// **Test: Emergency privilege cannot set oracle staleness threshold** + #[test] + fn set_oracle_staleness_threshold_requires_full_privilege() { + build_and_execute(|| { + assert_noop!( + Vaults::set_oracle_staleness_threshold( + RuntimeOrigin::signed(EMERGENCY_ADMIN), + 7_200_000 + ), + Error::::InsufficientPrivilege + ); + }); + } + + /// **Test: Regular user cannot set oracle staleness threshold** + #[test] + fn set_oracle_staleness_threshold_fails_for_non_manager() { + build_and_execute(|| { + assert_noop!( + Vaults::set_oracle_staleness_threshold(RuntimeOrigin::signed(ALICE), 7_200_000), + frame_support::error::BadOrigin + ); + }); + } + + /// **Test: New minimum deposit is enforced on vault creation** + #[test] + fn new_minimum_deposit_is_enforced() { + build_and_execute(|| { + // Raise minimum deposit to 200 DOT + let new_minimum = 200 * DOT; + assert_ok!(Vaults::set_minimum_deposit(RuntimeOrigin::root(), new_minimum)); + + // Try to create vault with less than new minimum (150 DOT) + assert_noop!( + Vaults::create_vault(RuntimeOrigin::signed(ALICE), 150 * DOT), + Error::::BelowMinimumDeposit + ); + + // Creating with exactly new minimum works + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), new_minimum)); + }); + } + + /// **Test: New minimum mint is enforced on minting** + #[test] + fn new_minimum_mint_is_enforced() { + build_and_execute(|| { + // Create vault first + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + // Raise minimum mint to 10 pUSD + let new_minimum = 10 * PUSD; + assert_ok!(Vaults::set_minimum_mint(RuntimeOrigin::root(), new_minimum)); + + // Try to mint less than new minimum (8 pUSD) + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 8 * PUSD), + Error::::BelowMinimumMint + ); + + // Minting with exactly new minimum works + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), new_minimum)); + }); + } +} + +mod not_configured { + use super::*; + + /// **Test: create_vault fails when pallet parameters are not configured** + #[test] + fn create_vault_fails_when_not_configured() { + new_test_ext().execute_with(|| { + // Kill a critical parameter to simulate unconfigured state + MinimumDeposit::::kill(); + + assert_noop!( + Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT), + Error::::NotConfigured + ); + }); + } + + /// **Test: mint fails when pallet parameters are not configured** + #[test] + fn mint_fails_when_not_configured() { + new_test_ext().execute_with(|| { + // Create vault while system is configured + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + // Kill a parameter + MinimumMint::::kill(); + + assert_noop!( + Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD), + Error::::NotConfigured + ); + }); + } + + /// **Test: liquidate fails when pallet parameters are not configured** + #[test] + fn liquidate_fails_when_not_configured() { + new_test_ext().execute_with(|| { + // Create and set up vault while system is configured + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Kill a parameter + MinimumCollateralizationRatio::::kill(); + + // Drop price to make vault undercollateralized + set_mock_price(Some(FixedU128::from_u32(3))); + + assert_noop!( + Vaults::liquidate_vault(RuntimeOrigin::signed(BOB), ALICE), + Error::::NotConfigured + ); + }); + } + + /// **Test: on_idle gracefully skips when parameters are not configured** + #[test] + fn on_idle_skips_when_not_configured() { + new_test_ext().execute_with(|| { + // Create vault while system is configured + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 200 * PUSD)); + + // Kill stale vault threshold + StaleVaultThreshold::::kill(); + + // Advance time past what would normally be the stale threshold + advance_timestamp(20_000_000); + + // on_idle should not panic, just return early + let weight = Vaults::on_idle(1, Weight::MAX); + // Should return base weight (zero in test config) since it returns early + assert_eq!(weight, Vaults::on_idle_base_weight()); + }); + } + + /// **Test: governance setters work when pallet is not fully configured** + /// + /// Governance setters must work before the system is ready because they + /// are the mechanism to configure the pallet. + #[test] + fn governance_setters_work_when_not_fully_configured() { + new_test_ext().execute_with(|| { + // Kill all parameters + MinimumCollateralizationRatio::::kill(); + InitialCollateralizationRatio::::kill(); + StabilityFee::::kill(); + LiquidationPenalty::::kill(); + MaximumIssuance::::kill(); + MaxLiquidationAmount::::kill(); + MaxPositionAmount::::kill(); + MinimumDeposit::::kill(); + MinimumMint::::kill(); + StaleVaultThreshold::::kill(); + OracleStalenessThreshold::::kill(); + + // All governance setters should still work + assert_ok!(Vaults::set_minimum_collateralization_ratio( + RuntimeOrigin::root(), + ratio(180) + )); + assert_ok!(Vaults::set_initial_collateralization_ratio( + RuntimeOrigin::root(), + ratio(200) + )); + assert_ok!(Vaults::set_stability_fee(RuntimeOrigin::root(), Permill::from_percent(4))); + assert_ok!(Vaults::set_liquidation_penalty( + RuntimeOrigin::root(), + Permill::from_percent(13) + )); + assert_ok!(Vaults::set_max_issuance(RuntimeOrigin::root(), 20_000_000 * PUSD)); + assert_ok!(Vaults::set_max_liquidation_amount( + RuntimeOrigin::root(), + 20_000_000 * PUSD + )); + assert_ok!(Vaults::set_max_position_amount(RuntimeOrigin::root(), 10_000_000 * PUSD)); + assert_ok!(Vaults::set_minimum_deposit(RuntimeOrigin::root(), 100 * DOT)); + assert_ok!(Vaults::set_minimum_mint(RuntimeOrigin::root(), 5 * PUSD)); + assert_ok!(Vaults::set_stale_vault_threshold(RuntimeOrigin::root(), 14_400_000)); + assert_ok!(Vaults::set_oracle_staleness_threshold(RuntimeOrigin::root(), 3_600_000)); + + // Now the system should be ready + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + }); + } +} +mod on_idle_coverage { + use super::*; + + /// **Test: `on_idle` cursor persists across blocks with `MaxOnIdleItems` = 1** + /// + /// When `MaxOnIdleItems` limits processing to 1 vault per call, + /// the cursor must persist so subsequent calls resume from where we left off. + #[test] + fn cursor_persists_across_blocks() { + build_and_execute(|| { + set_max_on_idle_items(1); + + // Create 3 vaults with debt so they accrue fees + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(BOB), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(BOB), 100 * PUSD)); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(CHARLIE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(CHARLIE), 100 * PUSD)); + + // Advance past stale threshold (4 hours = 14_400_000 ms) + advance_timestamp(15_000_000); + + // First on_idle: processes 1 vault, peeks next → stopped_early, cursor set + Vaults::on_idle(1, Weight::MAX); + assert!( + OnIdleCursor::::get().is_some(), + "Cursor should be set after partial pass" + ); + + // Second on_idle: resumes from cursor, processes 1, peeks next → stopped_early + Vaults::on_idle(1, Weight::MAX); + assert!( + OnIdleCursor::::get().is_some(), + "Cursor should still be set (1 more vault remaining)" + ); + + // Third on_idle: resumes, processes last vault, iter exhausted → cursor cleared + Vaults::on_idle(1, Weight::MAX); + assert!( + OnIdleCursor::::get().is_none(), + "Cursor should be cleared after full pass" + ); + + // Reset for try_state validation + set_max_on_idle_items(u32::MAX); + }); + } + + /// **Test: `on_idle` clears cursor after a full pass** + /// + /// When all vaults are processed in a single call, the cursor + /// should be cleared (not set). + #[test] + fn clears_cursor_after_full_pass() { + build_and_execute(|| { + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + // Advance past stale threshold + advance_timestamp(15_000_000); + + Vaults::on_idle(1, Weight::MAX); + + assert!( + OnIdleCursor::::get().is_none(), + "Cursor should be cleared after processing all vaults" + ); + }); + } + + /// **Test: `on_idle` advances cursor even when stale vault updates are skipped** + /// + /// If `update_vault_fees` fails for the current vault, pagination must still + /// progress; otherwise a low `MaxOnIdleItems` setting can get stuck forever + /// on the same failing account. + #[test] + fn cursor_advances_across_skipped_vaults() { + build_and_execute(|| { + set_max_on_idle_items(1); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(ALICE), 100 * PUSD)); + + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(BOB), 100 * DOT)); + assert_ok!(Vaults::mint(RuntimeOrigin::signed(BOB), 100 * PUSD)); + + advance_timestamp(15_000_000); + + // Force `update_vault_fees` to fail for every stale vault. + StabilityFee::::kill(); + + Vaults::on_idle(1, Weight::MAX); + assert_eq!( + OnIdleCursor::::get(), + Some(ALICE), + "Cursor should advance past the first skipped vault" + ); + + Vaults::on_idle(1, Weight::MAX); + assert!( + OnIdleCursor::::get().is_none(), + "Cursor should clear after later calls progress past skipped vaults" + ); + + // Restore for try_state validation. + StabilityFee::::put(Permill::from_percent(4)); + set_max_on_idle_items(u32::MAX); + }); + } +} + +mod try_state_violations { + use super::*; + + /// **Test: `do_try_state` fails when any parameter is not configured** + /// + /// Killing each of the 11 parameters one at a time should cause + /// `do_try_state` to return an error. + #[test] + fn fails_when_param_not_configured() { + macro_rules! assert_param_required { + ($storage:ty, $restore_value:expr) => { + <$storage>::kill(); + assert!( + VaultsPallet::::do_try_state().is_err(), + "do_try_state should fail when {} is not configured", + stringify!($storage) + ); + <$storage>::put($restore_value); + assert_ok!(VaultsPallet::::do_try_state()); + }; + } + + new_test_ext().execute_with(|| { + assert_ok!(VaultsPallet::::do_try_state()); + + assert_param_required!(MinimumCollateralizationRatio::, ratio(180)); + assert_param_required!(InitialCollateralizationRatio::, ratio(200)); + assert_param_required!(StabilityFee::, Permill::from_percent(4)); + assert_param_required!(LiquidationPenalty::, Permill::from_percent(13)); + assert_param_required!(MaximumIssuance::, 20_000_000 * PUSD); + assert_param_required!(MaxLiquidationAmount::, 20_000_000 * PUSD); + assert_param_required!(MaxPositionAmount::, 10_000_000 * PUSD); + assert_param_required!(MinimumDeposit::, 100 * DOT); + assert_param_required!(MinimumMint::, 5 * PUSD); + assert_param_required!(StaleVaultThreshold::, 14_400_000u64); + assert_param_required!(OracleStalenessThreshold::, 3_600_000u64); + }); + } + + /// **Test: `do_try_state` fails when InitialCR < MinimumCR** + #[test] + fn fails_when_initial_ratio_below_minimum() { + new_test_ext().execute_with(|| { + // MinimumCR = 180%, set InitialCR = 150% (below minimum) + InitialCollateralizationRatio::::put(ratio(150)); + + assert!( + VaultsPallet::::do_try_state().is_err(), + "Should fail when InitialCR < MinimumCR" + ); + }); + } + + /// **Test: `do_try_state` fails when MinimumCR <= 100%** + #[test] + fn fails_when_min_ratio_not_above_100() { + new_test_ext().execute_with(|| { + // Also lower InitialCR to avoid the InitialCR >= MinCR check failing first + MinimumCollateralizationRatio::::put(FixedU128::one()); + InitialCollateralizationRatio::::put(FixedU128::one()); + + assert!( + VaultsPallet::::do_try_state().is_err(), + "Should fail when MinimumCR = 100%" + ); + }); + } + + /// **Test: `do_try_state` fails when MaxPositionAmount > MaxLiquidationAmount** + #[test] + fn fails_when_max_position_exceeds_max_liquidation() { + new_test_ext().execute_with(|| { + MaxPositionAmount::::put(30_000_000 * PUSD); + MaxLiquidationAmount::::put(20_000_000 * PUSD); + + assert!( + VaultsPallet::::do_try_state().is_err(), + "Should fail when MaxPositionAmount > MaxLiquidationAmount" + ); + }); + } + + /// **Test: `do_try_state` fails when liquidated vault still has VaultDeposit hold** + #[test] + fn fails_when_liquidated_vault_has_deposit_hold() { + new_test_ext().execute_with(|| { + // Create a vault (holds collateral with VaultDeposit reason) + assert_ok!(Vaults::create_vault(RuntimeOrigin::signed(ALICE), 100 * DOT)); + + // Raw-write the vault status to InLiquidation without releasing the hold + VaultsStorage::::mutate(ALICE, |maybe_vault| { + let vault = maybe_vault.as_mut().expect("vault exists"); + vault.status = VaultStatus::InLiquidation; + }); + + assert!( + VaultsPallet::::do_try_state().is_err(), + "Should fail when InLiquidation vault still has VaultDeposit hold" + ); + }); + } + + /// **Test: `do_try_state` fails when healthy vault with debt has no collateral** + #[test] + fn fails_when_healthy_vault_with_debt_has_no_collateral() { + new_test_ext().execute_with(|| { + // Raw-insert a vault with debt but no collateral hold + let mut vault = Vault::::new(); + vault.principal = 100 * PUSD; + VaultsStorage::::insert(ALICE, vault); + + assert!( + VaultsPallet::::do_try_state().is_err(), + "Should fail when healthy vault has debt but no collateral" + ); + }); + } + + /// **Test: `do_try_state` fails when CurrentLiquidationAmount > MaxLiquidationAmount** + #[test] + fn fails_when_current_liquidation_exceeds_max() { + new_test_ext().execute_with(|| { + CurrentLiquidationAmount::::put(30_000_000 * PUSD); + + assert!( + VaultsPallet::::do_try_state().is_err(), + "Should fail when CurrentLiquidationAmount > MaxLiquidationAmount" + ); + }); + } +} diff --git a/substrate/frame/vaults/src/weights.rs b/substrate/frame/vaults/src/weights.rs new file mode 100644 index 0000000000000..464aac919584d --- /dev/null +++ b/substrate/frame/vaults/src/weights.rs @@ -0,0 +1,870 @@ +// This file is part of Substrate. + +// Copyright (C) Amforc AG. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Autogenerated weights for `pallet_vaults` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 +//! DATE: 2026-03-19, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `zur1-vm-devleo-001`, CPU: `AMD EPYC 9354 32-Core Processor` +//! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` + +// Executed Command: +// ./target/release/substrate-node +// benchmark +// pallet +// --chain +// dev +// --wasm-execution +// compiled +// --heap-pages +// 4096 +// --pallet +// pallet_vaults +// --extrinsic +// * +// --steps +// 50 +// --repeat +// 20 +// --output +// ./substrate/frame/vaults/src/weights.rs +// --header +// ./substrate/HEADER-APACHE2 +// --template +// ./substrate/.maintain/frame-weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] +#![allow(dead_code)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for `pallet_vaults`. +pub trait WeightInfo { + fn create_vault() -> Weight; + fn deposit_collateral() -> Weight; + fn withdraw_collateral() -> Weight; + fn mint() -> Weight; + fn repay() -> Weight; + fn liquidate_vault() -> Weight; + fn close_vault() -> Weight; + fn heal() -> Weight; + fn poke() -> Weight; + fn set_minimum_collateralization_ratio() -> Weight; + fn set_initial_collateralization_ratio() -> Weight; + fn set_stability_fee() -> Weight; + fn set_liquidation_penalty() -> Weight; + fn set_max_liquidation_amount() -> Weight; + fn set_max_issuance() -> Weight; + fn set_minimum_deposit() -> Weight; + fn set_minimum_mint() -> Weight; + fn set_stale_vault_threshold() -> Weight; + fn set_oracle_staleness_threshold() -> Weight; + fn set_max_position_amount() -> Weight; + fn on_idle_one_vault() -> Weight; +} + +/// Weights for `pallet_vaults` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `Vaults::MinimumDeposit` (r:1 w:0) + /// Proof: `Vaults::MinimumDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + fn create_vault() -> Weight { + // Proof Size summary in bytes: + // Measured: `588` + // Estimated: `3964` + // Minimum execution time: 69_549_000 picoseconds. + Weight::from_parts(71_620_000, 3964) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + fn deposit_collateral() -> Weight { + // Proof Size summary in bytes: + // Measured: `1369` + // Estimated: `3964` + // Minimum execution time: 104_779_000 picoseconds. + Weight::from_parts(108_160_000, 3964) + .saturating_add(T::DbWeight::get().reads(8_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MinimumDeposit` (r:1 w:0) + /// Proof: `Vaults::MinimumDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Proof: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Storage: `Vaults::OracleStalenessThreshold` (r:1 w:0) + /// Proof: `Vaults::OracleStalenessThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::InitialCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::InitialCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn withdraw_collateral() -> Weight { + // Proof Size summary in bytes: + // Measured: `1469` + // Estimated: `4934` + // Minimum execution time: 109_530_000 picoseconds. + Weight::from_parts(113_011_000, 4934) + .saturating_add(T::DbWeight::get().reads(12_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MinimumMint` (r:1 w:0) + /// Proof: `Vaults::MinimumMint` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:2 w:2) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaxPositionAmount` (r:1 w:0) + /// Proof: `Vaults::MaxPositionAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:0) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Proof: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Storage: `Vaults::OracleStalenessThreshold` (r:1 w:0) + /// Proof: `Vaults::OracleStalenessThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::InitialCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::InitialCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaximumIssuance` (r:1 w:0) + /// Proof: `Vaults::MaximumIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn mint() -> Weight { + // Proof Size summary in bytes: + // Measured: `1447` + // Estimated: `6208` + // Minimum execution time: 91_990_000 picoseconds. + Weight::from_parts(95_080_000, 6208) + .saturating_add(T::DbWeight::get().reads(15_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:2 w:2) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn repay() -> Weight { + // Proof Size summary in bytes: + // Measured: `1276` + // Estimated: `6208` + // Minimum execution time: 105_181_000 picoseconds. + Weight::from_parts(108_560_000, 6208) + .saturating_add(T::DbWeight::get().reads(8_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Proof: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Storage: `Vaults::OracleStalenessThreshold` (r:1 w:0) + /// Proof: `Vaults::OracleStalenessThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MinimumCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::MinimumCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::LiquidationPenalty` (r:1 w:0) + /// Proof: `Vaults::LiquidationPenalty` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::CurrentLiquidationAmount` (r:1 w:1) + /// Proof: `Vaults::CurrentLiquidationAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaxLiquidationAmount` (r:1 w:0) + /// Proof: `Vaults::MaxLiquidationAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn liquidate_vault() -> Weight { + // Proof Size summary in bytes: + // Measured: `1643` + // Estimated: `6196` + // Minimum execution time: 168_781_000 picoseconds. + Weight::from_parts(177_019_000, 6196) + .saturating_add(T::DbWeight::get().reads(15_u64)) + .saturating_add(T::DbWeight::get().writes(7_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + fn close_vault() -> Weight { + // Proof Size summary in bytes: + // Measured: `742` + // Estimated: `3964` + // Minimum execution time: 62_920_000 picoseconds. + Weight::from_parts(66_911_000, 3964) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: `Vaults::BadDebt` (r:1 w:1) + /// Proof: `Vaults::BadDebt` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + fn heal() -> Weight { + // Proof Size summary in bytes: + // Measured: `961` + // Estimated: `3675` + // Minimum execution time: 49_870_000 picoseconds. + Weight::from_parts(56_291_000, 3675) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn poke() -> Weight { + // Proof Size summary in bytes: + // Measured: `1276` + // Estimated: `3675` + // Minimum execution time: 52_211_000 picoseconds. + Weight::from_parts(53_789_000, 3675) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } + /// Storage: `Vaults::InitialCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::InitialCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MinimumCollateralizationRatio` (r:1 w:1) + /// Proof: `Vaults::MinimumCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_minimum_collateralization_ratio() -> Weight { + // Proof Size summary in bytes: + // Measured: `405` + // Estimated: `1501` + // Minimum execution time: 15_431_000 picoseconds. + Weight::from_parts(16_280_000, 1501) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MinimumCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::MinimumCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::InitialCollateralizationRatio` (r:1 w:1) + /// Proof: `Vaults::InitialCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_initial_collateralization_ratio() -> Weight { + // Proof Size summary in bytes: + // Measured: `405` + // Estimated: `1501` + // Minimum execution time: 15_560_000 picoseconds. + Weight::from_parts(16_619_000, 1501) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::StabilityFee` (r:1 w:1) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:0 w:1) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + fn set_stability_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `504` + // Estimated: `1493` + // Minimum execution time: 15_369_000 picoseconds. + Weight::from_parts(16_170_000, 1493) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: `Vaults::LiquidationPenalty` (r:1 w:1) + /// Proof: `Vaults::LiquidationPenalty` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + fn set_liquidation_penalty() -> Weight { + // Proof Size summary in bytes: + // Measured: `389` + // Estimated: `1489` + // Minimum execution time: 11_099_000 picoseconds. + Weight::from_parts(11_810_000, 1489) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MaxPositionAmount` (r:1 w:0) + /// Proof: `Vaults::MaxPositionAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaxLiquidationAmount` (r:1 w:1) + /// Proof: `Vaults::MaxLiquidationAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_max_liquidation_amount() -> Weight { + // Proof Size summary in bytes: + // Measured: `405` + // Estimated: `1501` + // Minimum execution time: 15_410_000 picoseconds. + Weight::from_parts(16_179_000, 1501) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MaximumIssuance` (r:1 w:1) + /// Proof: `Vaults::MaximumIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_max_issuance() -> Weight { + // Proof Size summary in bytes: + // Measured: `395` + // Estimated: `1501` + // Minimum execution time: 13_951_000 picoseconds. + Weight::from_parts(14_720_000, 1501) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MinimumDeposit` (r:1 w:1) + /// Proof: `Vaults::MinimumDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_minimum_deposit() -> Weight { + // Proof Size summary in bytes: + // Measured: `391` + // Estimated: `1501` + // Minimum execution time: 14_231_000 picoseconds. + Weight::from_parts(15_000_000, 1501) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MinimumMint` (r:1 w:1) + /// Proof: `Vaults::MinimumMint` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_minimum_mint() -> Weight { + // Proof Size summary in bytes: + // Measured: `365` + // Estimated: `1501` + // Minimum execution time: 11_019_000 picoseconds. + Weight::from_parts(11_880_000, 1501) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::StaleVaultThreshold` (r:1 w:1) + /// Proof: `Vaults::StaleVaultThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + fn set_stale_vault_threshold() -> Weight { + // Proof Size summary in bytes: + // Measured: `362` + // Estimated: `1493` + // Minimum execution time: 10_480_000 picoseconds. + Weight::from_parts(11_141_000, 1493) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::OracleStalenessThreshold` (r:1 w:1) + /// Proof: `Vaults::OracleStalenessThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + fn set_oracle_staleness_threshold() -> Weight { + // Proof Size summary in bytes: + // Measured: `393` + // Estimated: `1493` + // Minimum execution time: 11_190_000 picoseconds. + Weight::from_parts(11_911_000, 1493) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MaxLiquidationAmount` (r:1 w:0) + /// Proof: `Vaults::MaxLiquidationAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaxPositionAmount` (r:1 w:1) + /// Proof: `Vaults::MaxPositionAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_max_position_amount() -> Weight { + // Proof Size summary in bytes: + // Measured: `405` + // Estimated: `1501` + // Minimum execution time: 15_419_000 picoseconds. + Weight::from_parts(16_690_000, 1501) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StaleVaultThreshold` (r:1 w:0) + /// Proof: `Vaults::StaleVaultThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::OnIdleCursor` (r:1 w:1) + /// Proof: `Vaults::OnIdleCursor` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `Vaults::Vaults` (r:2 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn on_idle_one_vault() -> Weight { + // Proof Size summary in bytes: + // Measured: `1348` + // Estimated: `6118` + // Minimum execution time: 59_950_000 picoseconds. + Weight::from_parts(61_800_000, 6118) + .saturating_add(T::DbWeight::get().reads(10_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `Vaults::MinimumDeposit` (r:1 w:0) + /// Proof: `Vaults::MinimumDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + fn create_vault() -> Weight { + // Proof Size summary in bytes: + // Measured: `588` + // Estimated: `3964` + // Minimum execution time: 69_549_000 picoseconds. + Weight::from_parts(71_620_000, 3964) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + fn deposit_collateral() -> Weight { + // Proof Size summary in bytes: + // Measured: `1369` + // Estimated: `3964` + // Minimum execution time: 104_779_000 picoseconds. + Weight::from_parts(108_160_000, 3964) + .saturating_add(RocksDbWeight::get().reads(8_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MinimumDeposit` (r:1 w:0) + /// Proof: `Vaults::MinimumDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Proof: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Storage: `Vaults::OracleStalenessThreshold` (r:1 w:0) + /// Proof: `Vaults::OracleStalenessThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::InitialCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::InitialCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn withdraw_collateral() -> Weight { + // Proof Size summary in bytes: + // Measured: `1469` + // Estimated: `4934` + // Minimum execution time: 109_530_000 picoseconds. + Weight::from_parts(113_011_000, 4934) + .saturating_add(RocksDbWeight::get().reads(12_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MinimumMint` (r:1 w:0) + /// Proof: `Vaults::MinimumMint` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:2 w:2) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaxPositionAmount` (r:1 w:0) + /// Proof: `Vaults::MaxPositionAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:0) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Proof: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Storage: `Vaults::OracleStalenessThreshold` (r:1 w:0) + /// Proof: `Vaults::OracleStalenessThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::InitialCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::InitialCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaximumIssuance` (r:1 w:0) + /// Proof: `Vaults::MaximumIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn mint() -> Weight { + // Proof Size summary in bytes: + // Measured: `1447` + // Estimated: `6208` + // Minimum execution time: 91_990_000 picoseconds. + Weight::from_parts(95_080_000, 6208) + .saturating_add(RocksDbWeight::get().reads(15_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:2 w:2) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn repay() -> Weight { + // Proof Size summary in bytes: + // Measured: `1276` + // Estimated: `6208` + // Minimum execution time: 105_181_000 picoseconds. + Weight::from_parts(108_560_000, 6208) + .saturating_add(RocksDbWeight::get().reads(8_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Proof: UNKNOWN KEY `0x8e4ea319be654b24a0b5db46b321e662` (r:1 w:0) + /// Storage: `Vaults::OracleStalenessThreshold` (r:1 w:0) + /// Proof: `Vaults::OracleStalenessThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MinimumCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::MinimumCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::LiquidationPenalty` (r:1 w:0) + /// Proof: `Vaults::LiquidationPenalty` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::CurrentLiquidationAmount` (r:1 w:1) + /// Proof: `Vaults::CurrentLiquidationAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaxLiquidationAmount` (r:1 w:0) + /// Proof: `Vaults::MaxLiquidationAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn liquidate_vault() -> Weight { + // Proof Size summary in bytes: + // Measured: `1643` + // Estimated: `6196` + // Minimum execution time: 168_781_000 picoseconds. + Weight::from_parts(177_019_000, 6196) + .saturating_add(RocksDbWeight::get().reads(15_u64)) + .saturating_add(RocksDbWeight::get().writes(7_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Balances::Holds` (r:1 w:1) + /// Proof: `Balances::Holds` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + fn close_vault() -> Weight { + // Proof Size summary in bytes: + // Measured: `742` + // Estimated: `3964` + // Minimum execution time: 62_920_000 picoseconds. + Weight::from_parts(66_911_000, 3964) + .saturating_add(RocksDbWeight::get().reads(5_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: `Vaults::BadDebt` (r:1 w:1) + /// Proof: `Vaults::BadDebt` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + fn heal() -> Weight { + // Proof Size summary in bytes: + // Measured: `961` + // Estimated: `3675` + // Minimum execution time: 49_870_000 picoseconds. + Weight::from_parts(56_291_000, 3675) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: `Vaults::Vaults` (r:1 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn poke() -> Weight { + // Proof Size summary in bytes: + // Measured: `1276` + // Estimated: `3675` + // Minimum execution time: 52_211_000 picoseconds. + Weight::from_parts(53_789_000, 3675) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(4_u64)) + } + /// Storage: `Vaults::InitialCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::InitialCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MinimumCollateralizationRatio` (r:1 w:1) + /// Proof: `Vaults::MinimumCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_minimum_collateralization_ratio() -> Weight { + // Proof Size summary in bytes: + // Measured: `405` + // Estimated: `1501` + // Minimum execution time: 15_431_000 picoseconds. + Weight::from_parts(16_280_000, 1501) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MinimumCollateralizationRatio` (r:1 w:0) + /// Proof: `Vaults::MinimumCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::InitialCollateralizationRatio` (r:1 w:1) + /// Proof: `Vaults::InitialCollateralizationRatio` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_initial_collateralization_ratio() -> Weight { + // Proof Size summary in bytes: + // Measured: `405` + // Estimated: `1501` + // Minimum execution time: 15_560_000 picoseconds. + Weight::from_parts(16_619_000, 1501) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::StabilityFee` (r:1 w:1) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:0 w:1) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + fn set_stability_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `504` + // Estimated: `1493` + // Minimum execution time: 15_369_000 picoseconds. + Weight::from_parts(16_170_000, 1493) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: `Vaults::LiquidationPenalty` (r:1 w:1) + /// Proof: `Vaults::LiquidationPenalty` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + fn set_liquidation_penalty() -> Weight { + // Proof Size summary in bytes: + // Measured: `389` + // Estimated: `1489` + // Minimum execution time: 11_099_000 picoseconds. + Weight::from_parts(11_810_000, 1489) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MaxPositionAmount` (r:1 w:0) + /// Proof: `Vaults::MaxPositionAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaxLiquidationAmount` (r:1 w:1) + /// Proof: `Vaults::MaxLiquidationAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_max_liquidation_amount() -> Weight { + // Proof Size summary in bytes: + // Measured: `405` + // Estimated: `1501` + // Minimum execution time: 15_410_000 picoseconds. + Weight::from_parts(16_179_000, 1501) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MaximumIssuance` (r:1 w:1) + /// Proof: `Vaults::MaximumIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_max_issuance() -> Weight { + // Proof Size summary in bytes: + // Measured: `395` + // Estimated: `1501` + // Minimum execution time: 13_951_000 picoseconds. + Weight::from_parts(14_720_000, 1501) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MinimumDeposit` (r:1 w:1) + /// Proof: `Vaults::MinimumDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_minimum_deposit() -> Weight { + // Proof Size summary in bytes: + // Measured: `391` + // Estimated: `1501` + // Minimum execution time: 14_231_000 picoseconds. + Weight::from_parts(15_000_000, 1501) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MinimumMint` (r:1 w:1) + /// Proof: `Vaults::MinimumMint` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_minimum_mint() -> Weight { + // Proof Size summary in bytes: + // Measured: `365` + // Estimated: `1501` + // Minimum execution time: 11_019_000 picoseconds. + Weight::from_parts(11_880_000, 1501) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::StaleVaultThreshold` (r:1 w:1) + /// Proof: `Vaults::StaleVaultThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + fn set_stale_vault_threshold() -> Weight { + // Proof Size summary in bytes: + // Measured: `362` + // Estimated: `1493` + // Minimum execution time: 10_480_000 picoseconds. + Weight::from_parts(11_141_000, 1493) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::OracleStalenessThreshold` (r:1 w:1) + /// Proof: `Vaults::OracleStalenessThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + fn set_oracle_staleness_threshold() -> Weight { + // Proof Size summary in bytes: + // Measured: `393` + // Estimated: `1493` + // Minimum execution time: 11_190_000 picoseconds. + Weight::from_parts(11_911_000, 1493) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Vaults::MaxLiquidationAmount` (r:1 w:0) + /// Proof: `Vaults::MaxLiquidationAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Vaults::MaxPositionAmount` (r:1 w:1) + /// Proof: `Vaults::MaxPositionAmount` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn set_max_position_amount() -> Weight { + // Proof Size summary in bytes: + // Measured: `405` + // Estimated: `1501` + // Minimum execution time: 15_419_000 picoseconds. + Weight::from_parts(16_690_000, 1501) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StaleVaultThreshold` (r:1 w:0) + /// Proof: `Vaults::StaleVaultThreshold` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vaults::OnIdleCursor` (r:1 w:1) + /// Proof: `Vaults::OnIdleCursor` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `Vaults::Vaults` (r:2 w:1) + /// Proof: `Vaults::Vaults` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + /// Storage: `Vaults::StabilityFee` (r:1 w:0) + /// Proof: `Vaults::StabilityFee` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Vaults::PreviousStabilityFee` (r:1 w:0) + /// Proof: `Vaults::PreviousStabilityFee` (`max_values`: Some(1), `max_size`: Some(12), added: 507, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:1 w:1) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:1 w:1) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn on_idle_one_vault() -> Weight { + // Proof Size summary in bytes: + // Measured: `1348` + // Estimated: `6118` + // Minimum execution time: 59_950_000 picoseconds. + Weight::from_parts(61_800_000, 6118) + .saturating_add(RocksDbWeight::get().reads(10_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } +} diff --git a/umbrella/Cargo.toml b/umbrella/Cargo.toml index a45cbb14343c5..b10f0fd205ebe 100644 --- a/umbrella/Cargo.toml +++ b/umbrella/Cargo.toml @@ -164,6 +164,7 @@ std = [ "pallet-tx-pause?/std", "pallet-uniques?/std", "pallet-utility?/std", + "pallet-vaults?/std", "pallet-verify-signature?/std", "pallet-vesting?/std", "pallet-whitelist?/std", @@ -348,6 +349,7 @@ runtime-benchmarks = [ "pallet-tx-pause?/runtime-benchmarks", "pallet-uniques?/runtime-benchmarks", "pallet-utility?/runtime-benchmarks", + "pallet-vaults?/runtime-benchmarks", "pallet-verify-signature?/runtime-benchmarks", "pallet-vesting?/runtime-benchmarks", "pallet-whitelist?/runtime-benchmarks", @@ -494,6 +496,7 @@ try-runtime = [ "pallet-tx-pause?/try-runtime", "pallet-uniques?/try-runtime", "pallet-utility?/try-runtime", + "pallet-vaults?/try-runtime", "pallet-verify-signature?/try-runtime", "pallet-vesting?/try-runtime", "pallet-whitelist?/try-runtime", @@ -723,6 +726,7 @@ runtime-full = [ "pallet-tx-pause", "pallet-uniques", "pallet-utility", + "pallet-vaults", "pallet-verify-signature", "pallet-vesting", "pallet-whitelist", @@ -1831,6 +1835,11 @@ default-features = false optional = true path = "../substrate/frame/utility" +[dependencies.pallet-vaults] +default-features = false +optional = true +path = "../substrate/frame/vaults" + [dependencies.pallet-verify-signature] default-features = false optional = true @@ -2851,6 +2860,7 @@ default-features = false optional = true path = "../substrate/primitives/panic-handler" + [dependencies.sp-rpc] default-features = false optional = true diff --git a/umbrella/src/lib.rs b/umbrella/src/lib.rs index be821fb478352..6d371797c6b47 100644 --- a/umbrella/src/lib.rs +++ b/umbrella/src/lib.rs @@ -779,6 +779,10 @@ pub use pallet_uniques; #[cfg(feature = "pallet-utility")] pub use pallet_utility; +/// FRAME vaults pallet. +#[cfg(feature = "pallet-vaults")] +pub use pallet_vaults; + /// FRAME verify signature pallet. #[cfg(feature = "pallet-verify-signature")] pub use pallet_verify_signature;