Skip to content

Introduce Vaults pallet (part of the pUSD Project) #10699

Open
lrazovic wants to merge 61 commits intoparitytech:masterfrom
amforc:leo/vaults
Open

Introduce Vaults pallet (part of the pUSD Project) #10699
lrazovic wants to merge 61 commits intoparitytech:masterfrom
amforc:leo/vaults

Conversation

@lrazovic
Copy link
Copy Markdown

@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;                         // pallet-assets for pUSD
    type AssetId = u32;
    type TimeProvider = Timestamp;
    type Oracle = OraclePallet;                  // Must implement `ProvidePrice` trait
    type AuctionsHandler = Auctions;             // Liquidation auction handler
    type FeeHandler = ResolveTo<Treasury, Balances>;   // DOT from surplus auctions
    type SurplusHandler = ResolveTo<Treasury, Assets>; // pUSD surplus transfers
    type ManagerOrigin = EnsureVaultsManager;    // Governance (returns VaultsManagerLevel)
    type WeightInfo = pallet_vaults::weights::SubstrateWeight<Runtime>;
    #[cfg(feature = "runtime-benchmarks")]
    type BenchmarkHelper = VaultsBenchmarkHelper;
    type StablecoinAssetId = StablecoinAssetId;  // pUSD asset ID
    type InsuranceFund = InsuranceFund;          // Account receiving protocol revenue
    type MaxOnIdleItems = ConstU32<100>;         // Safety limit for on_idle processing
    type CollateralLocation = CollateralLocation; // XCM Location of collateral asset
}
  1. Add to construct_runtime!:
construct_runtime!(
  pub enum Runtime {
    // ... other pallets
    Vaults: pallet_vaults,
  }
);
  1. For existing chains, include the migration:
pub type Migrations = (
   pallet_vaults::migrations::MigrateV0ToV1<Runtime, VaultsInitialConfig>,
);

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(buyer, collateral, payment, recipient, vault_owner)?;

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. Interest is minted to the Insurance Fund when it accrues ("mint-on-accrual" model).
  • 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
  7. poke - Force fee accrual on any vault

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
  • Migration from V0 to V1

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

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
Waiting to generate the weights after the implementation is approved.
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
Copy Markdown
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 054c6e6.

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

// Update fees
Copy link
Copy Markdown
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
Copy Markdown
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.

Copy link
Copy Markdown
Author

@lrazovic lrazovic Jan 5, 2026

Choose a reason for hiding this comment

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

Removed in 65030aa.

// 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
Copy Markdown
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reworked a bit in 054c6e6.

remaining_collateral,
total_obligation,
)?
.expect("total_obligation is non-zero; qed");
Copy link
Copy Markdown
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Converted the .expects into errors in 054c6e6.

Copy link
Copy Markdown
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

These types will be reused by the other pallets in the pUSD family. Currently they're only used by this pallet, but once we start adding the other pallets, they'll be imported from this common crate. If you have a suggestion for better placement, I'm happy to move them accordingly!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Substrate is not the right place for these items. For Frame pallets common items usually located under support crate. But these traits and types I would locate for now next to their implementations. Later if we have impls for them, we modify them if needed and move to support. But for now I would keep it simple.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we still need to move this under frame

}
}

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

Choose a reason for hiding this comment

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

noe = now?

Copy link
Copy Markdown
Author

@lrazovic lrazovic Jan 5, 2026

Choose a reason for hiding this comment

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

Fixed in ff5479b.

Copy link
Copy Markdown
Contributor

@muharem muharem left a comment

Choose a reason for hiding this comment

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

reviewed the implementation, have not checked the tests and the benchmarks. gonna continue tomorrow.

type AssetId: Parameter + Member + Copy + MaybeSerializeDeserialize + MaxEncodedLen;

/// Time provider for fee accrual using UNIX timestamps.
type TimeProvider: Time;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we do not have a working time provider solution. if you have not too, we need to migrate this into block provider. The block provider should be configurable type. You can check society pallet for an example.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

We actually started with block numbers, but we switched to timestamps (via pallet-timestamps) because the interest accrual domain benefits from real time rather than block-number-as-proxy

Copy link
Copy Markdown
Contributor

@muharem muharem Mar 12, 2026

Choose a reason for hiding this comment

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

its accuracy is prev_timestamp + 3sec < current_timestamp > prev_timestamp + 30sec. not that great, and relies on prev timestamp value. I do not know a case where we used it. I would not do it with this pallet if we do not really know what are we doing.

actually that ^ accuracy only applicable for Relay Chain. for Asset Hubs that 3 sec is 0. tho we probably can pass timestamps from RC to AH, but only from the RC block AH building its current block.

if we use block numbers, on AH its gonna be RC block numbers too.

cc: @kianenigma @seadanda

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Using the AH Timestamp, until AH collators are the same as RC validators (something that is envisioned long term) is a no-go IMO. It is way too little security.

Using the relay Timestamp is IMO a good option. It is ultimately an oraclized value that the runtime can never know exactly what it is, but relying on the validators is as good as it gets. I was under the impression that the Relay Timestamp is part of the PersistedValidationData, but it is not. The state root is, and we can use a state-proof against that. Not ideal though. If we are to use this, I would explore doing it properly and via #6235

Relay block number is also a safe Plan B. although it has its own set of trade-offs: If we ever ever change the relay block time, all hell breaks loose. Kusama/JAM is already being toyed with having shorter relay slots.

Verdict:

  1. Dispatch claude with a good prompt to fix Customize-able ParacnainInherentDataProvider  #6235. If not overly complicated, this feature will help multiple systems.
  2. If not straightforward, then use relay timestamp
  3. AH timestamp not a viable option IMO.

Copy link
Copy Markdown
Author

@lrazovic lrazovic Mar 22, 2026

Choose a reason for hiding this comment

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

If I understand correctly, paritytech/polkadot-sdk#10678 introduced a KeyToIncludeInRelayProof runtime API that lets parachains request arbitrary relay chain storage keys in the inherent proof. Building on that, accessing the RC Timestamp should now be straightforward. A first draft (untested) is available here: https://gist.github.com/lrazovic/bf701015bcc8e482207c1034b5b7a346

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice, without looking at the gist, the approach sounds exactly correct to me.

let total_issuance = T::Asset::total_issuance(T::StablecoinAssetId::get());
ensure!(
total_issuance.saturating_add(amount) <=
MaximumIssuance::<T>::get().ok_or(Error::<T>::NotConfigured)?,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should we have some type to get PSM reserved capacity and subtract it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we can do this when we locate PsmInterface trait in frame/support

Comment on lines +2292 to +2293
vault.accrued_interest.saturating_accrue(accrued);
vault.last_fee_update = now;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think I can update my vault lets say every block and have accrued always set to 0 and last_fee_update to now.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, addressed and tested in f1a9638

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if StabilityFee updated at now, it takes effect for the vaults from last_fee_update, not from now. right? is this expected?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

For correctness, In 97d0989 I introduced a PreviousStabilityFee storage item that lets update_vault_fees split interest accrual at the fee change boundary, so the old rate applies before the change and the new rate only applies after it


Self::deposit_event(Event::BadDebtRepaid { amount: burned });

Ok(())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we can make this call for free if there was anything to burn

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in cdb14d6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

its should be for free only if the execution really healed some amount. like its done for liquidate_vault call.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated in 1868737

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Substrate is not the right place for these items. For Frame pallets common items usually located under support crate. But these traits and types I would locate for now next to their implementations. Later if we have impls for them, we modify them if needed and move to support. But for now I would keep it simple.

Comment on lines +1979 to +1987
if !keeper_incentive.is_zero() {
T::Asset::transfer(
T::StablecoinAssetId::get(),
&T::InsuranceFund::get(),
keeper,
keeper_incentive,
Preservation::Expendable,
)?;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this doesn't seem right to me, the keeper_incentive transfer using ? propagates any transfer failure, but all of the auction finalization logic, collateral release, CurrentLiquidationAmount decrement, bad-debt recording, and vault removal sits after this point. If the Insurance Fund has insufficient pUSD for the keeper payment, the entire complete_auction call exits early and none of those steps execute.

This seems to leave the vault permanently in VaultStatus::InLiquidation with collateral held under HoldReason::Seized and no way (not entirely sure if this is true) to finalize it. All user-callable vault operations gate on VaultStatus::Healthy, and there's no force_complete_auction or similar recovery extrinsic in the pallet.

The keeper payment should be non-blocking for auction finalization. One option is to attempt the transfer and, on failure, record the unpaid incentive for later settlement rather than aborting the whole completion. Something like:

if !keeper_incentive.is_zero() {
    let payment_result = T::Asset::transfer(
        T::StablecoinAssetId::get(),
        &T::InsuranceFund::get(),
        keeper,
        keeper_incentive,
        Preservation::Expendable,
    );
    if let Err(_) = payment_result {
        // Record as owed; don't block finalization
        UnpaidKeeperIncentives::<T>::mutate(keeper, |owed| {
            owed.saturating_accrue(keeper_incentive);
        });
        Self::deposit_event(Event::KeeperIncentiveDeferred {
            keeper: keeper.clone(),
            amount: keeper_incentive,
        });
    }
}
// Finalization (collateral release, CLA decrement, vault removal) proceeds unconditionally

Alternatively, I am not sure if a governance-callable force_complete_auction extrinsic would make sense to provide a recovery path for any vault stuck in InLiquidation due to this or similar failures.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point, part of 389590f. But I'm not sure if adding a new UnpaidKeeperIncentives storage item for this edge-case makes sense, we just emit an event for now

/// The currency used for collateral (native DOT).
/// Collateral is managed via `pallet_balances` using holds.
/// The Balance type is derived from this and must implement `FixedPointOperand`.
type Currency: FungibleMutate<Self::AccountId, Balance: FixedPointOperand>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

how about Collateral? Currency is outdated naming, refers more to Currency trait

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Renamed in 6cef850

/// The asset used for pUSD debt.
/// Constrained to use the same Balance type as Currency.
/// Also implements `Balanced` for creating credits during surplus transfers.
type Asset: FungibleMutate<Self::AccountId, Balance = BalanceOf<Self>>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How about StableAsset?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Renamed in 6cef850

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.

8 participants