Skip to content

Conversation

@lrazovic
Copy link

@lrazovic lrazovic commented Dec 19, 2025

Description

This PR introduces pallet-vaults, a new FRAME pallet that implements a Collateralized Debt Position (CDP) system for creating over-collateralized stablecoin loans on Substrate-based blockchains. The pallet allows users to lock up DOT as collateral and mint pUSD against it.

Integration

For Runtime Developers

To integrate pallet-vaults into your runtime:

  1. Add dependency to your runtime's Cargo.toml:
pallet-vaults = { version = "0.1.0", default-features = false }
  1. Implement the Config trait in your runtime:
impl pallet_vaults::Config for Runtime {
  type Currency = Balances; // Native token for collateral
  type RuntimeHoldReason = RuntimeHoldReason;
  type Asset = Assets; // Assets pallet for stablecoin
  type AssetId = u32;
  type StablecoinAssetId = StablecoinAssetId; // Constant: ID of pUSD
  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;
  type StaleVaultThreshold = StaleVaultThreshold;
  type OracleStalenessThreshold = OracleStalenessThreshold;
  type Oracle = OraclePallet; // Price oracle (implmenting the ProvidePrice trait)
  type CollateralLocation = CollateralLocation;
  type AuctionsHandler = Auctions; // Liquidation handler
  type ManagerOrigin = EnsureVaultsManager; // Governance origin
  type WeightInfo = pallet_vaults::weights::SubstrateWeight<Runtime>;
}
  1. Add to construct_runtime!:
construct_runtime!(
  pub enum Runtime {
    // ... other pallets
    Vaults: pallet_vaults,
  }
);

For Pallet Developers

Other pallets can interact with vaults via the CollateralManager trait:

use sp_pusd::CollateralManager;

// Get current DOT price from oracle
if let Some(price) = <pallet_vaults::Pallet<T> as CollateralManager<AccountId>>::get_dot_price() {
  // Use the price data
}

// Execute a purchase during auction
CollateralManager::execute_purchase(purchase_params)?;

Review Notes

Key Features

  • Per-Account Vaults: Each account can have at most one vault. Collateral is held in the user's account via MutateHold (not transferred to a pallet account)
  • Over-Collateralization: Two-tier ratio system - Initial CR for minting/withdrawing (e.g., 200%) and Minimum CR as liquidation threshold (e.g., 180%)
  • Stability Fees: Time-based interest accrual using timestamps
  • Liquidation System: Unsafe vaults can be liquidated by anyone, with collateral auctioned via AuctionsHandler
  • Bad Debt Tracking: Records shortfalls when auctions fail to cover debt, healable via heal() extrinsic
  • Tiered Governance: Full privileges for all parameters, Emergency privileges for defensive actions only (lowering debt ceiling)

Vault Lifecycle:

  1. create_vault - Create a new Vault and lock initial collateral
  2. deposit_collateral / withdraw_collateral - Manage collateral
  3. mint - Borrow pUSD against collateral
  4. repay - Burn pUSD to reduce debt (interest paid first)
  5. close_vault - Close debt-free vault, release collateral
  6. liquidate_vault - Liquidate unsafe vaults

Hold Reasons:

  • VaultDeposit - Collateral backing active vaults
  • Seized - Collateral under liquidation, pending auction

Testing

The pallet includes comprehensive tests covering:

  • Vault creation, deposits, withdrawals, and closure
  • Minting and repayment with interest accrual
  • Collateralization ratio enforcement
  • Liquidation flow and auction integration
  • Oracle staleness handling
  • Governance parameter updates

@lrazovic lrazovic requested a review from a team as a code owner December 19, 2025 15:14
@cla-bot-2021
Copy link

cla-bot-2021 bot commented Dec 19, 2025

User @lrazovic, please sign the CLA here.

@seadanda seadanda self-requested a review December 19, 2025 15:59
@seadanda seadanda added the T2-pallets This PR/Issue is related to a particular pallet. label Dec 19, 2025
@seadanda seadanda requested a review from Overkillus December 19, 2025 16:17
Comment on lines +232 to +233
/// 365.25 days × 24 hours × 60 minutes × 60 seconds × 1000 milliseconds = 31,557,600,000
const MILLIS_PER_YEAR: u64 = 31_557_600_000;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just put the calculation here instead of having it in a comment.

// Cannot close a vault that's being liquidated
ensure!(vault.status == VaultStatus::Healthy, Error::<T>::VaultInLiquidation);

// Update fees
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please tell claude to not over comment stuff. The function name is descriptive enough.

Comment on lines +656 to +683
/// This ensures the Insurance Fund account is created with a provider reference so it can
/// receive any amount (including below ED) without risk of being reaped.
fn on_runtime_upgrade() -> Weight {
let on_chain_version = StorageVersion::get::<Pallet<T>>();

if on_chain_version < 1 {
Self::ensure_insurance_fund_exists();
StorageVersion::new(1).put::<Pallet<T>>();

log::info!(
target: LOG_TARGET,
"Migrated storage from version {:?} to 1",
on_chain_version
);

// Weight: 1 read (storage version) + 1 read (account_exists) + 2 writes
// (inc_providers + storage version)
T::DbWeight::get().reads_writes(2, 2)
} else {
log::debug!(
target: LOG_TARGET,
"No migration needed, on-chain version {:?}",
on_chain_version
);
// Weight: 1 read (storage version check)
T::DbWeight::get().reads(1)
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be some extra code in the runtime when we add this pallet.

// Update cursor for next block
match last_processed {
Some(last) => {
if Vaults::<T>::iter_from(Vaults::<T>::hashed_key_for(&last)).nth(1).is_none() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we are doing one more iteration that isn't part of the weight? Maybe we should just do this at the top of the function.

remaining_collateral,
total_obligation,
)?
.expect("total_obligation is non-zero; qed");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really a problem to convert this into an error, better safe than sorry here.

Comment on lines +1210 to +1212
CurrentLiquidationAmount::<T>::mutate(|current| {
current.saturating_accrue(total_debt);
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
CurrentLiquidationAmount::<T>::mutate(|current| {
current.saturating_accrue(total_debt);
});
CurrentLiquidationAmount::<T>::put(new_liquidation_amount);

Self::deposit_event(Event::CollateralWithdrawn { owner: who.clone(), amount });

// Remove empty vaults immediately (no collateral + no debt).
if remaining_collateral.is_zero() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we close a vault, we should call some do_close_vault method that ensures we do the same checks between this function here and close_vault. Because right now it looks like principal handling is different between both methods.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why these types are not part of the vault crate right now. This code should be clearly not be in this folder.

}
}

/// Mock oracle adapter that provides a fixed price for noe.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noe = now?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T2-pallets This PR/Issue is related to a particular pallet.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants