Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
Your Vault Prices Collateral at $1: 5 Ways to Bad Debt

Your Vault Prices Collateral at $1: 5 Ways to Bad Debt

17 min
On this page▾

TL;DR — Quick Summary

  • A lending vault is only as safe as the price it believes for its collateral, and how much of that collateral it lets in. Get either wrong and the vault ends up with bad debt: loans worth more than anything that can be sold to repay them.
  • Don't price collateral with a constant. In March 2026, a token was marked at $1.13 while it traded near $0.63. In our demo, a stablecoin that crashed to $0.10 on our pool still priced at $1.00, and $112,500 of collateral took 900,000 tUSDC.
  • Cap every collateral at what you could actually sell in a crash. In August 2026, a thin token was looped 98 times and about $120M was borrowed. In our demo, a token with $1.3M of liquidity backed 120,000,000 of borrows.
  • Set exchange rates from cash you tracked, not balanceOf, so a donation can't inflate them. Stop new borrows when collateral depegs.
  • Put every cap increase and new collateral behind a timelock, so depositors can see the change coming and leave.

The video runs every attack and every fix on screen. The article below covers the same ground, with the code, if you would rather read it.

Introduction

Your vault says a dollar thirteen. The market pays sixty-three cents.
In March 2026, that is exactly how one token was priced: marked at $1.13 by lending vaults with a hardcoded oracle, while it traded near $0.63. A few months earlier, frozen prices kept liquidations from firing while about $285M of debt was linked to one fund's collapse. Your vault can look fully collateralised and still be lending against a number nobody can sell for.
So before anything else, here is a dare. List every collateral your vault accepts, and where each price comes from. If any price never moves, keep reading.
Since 2024, teams and audit firms have paid us more than half a million dollars to find what breaks in their code before attackers do. This article walks through five ways a lending vault ends up with bad debt, the fix for each one in code, and the one list to check today. Every demo runs locally: an anvil chain on 127.0.0.1, our own minimal stand-ins (a Compound-style lending market, a constant-product pool, a settable oracle and a curator vault), test tokens (tUSDC, tUSDX, THIN) and anvil's public dev keys. None of it is real protocol code, and nothing touches a live network. The full harness is public: ZealynxSecurity/zealynx-demos, newtech-08-vault.

How a lending vault works

Depositors put USDC into a vault. Standards like ERC-4626 define it. A curator decides which lending markets get that money. Borrowers post collateral and take loans. An oracle prices the collateral, and when a loan goes underwater, liquidators sell the collateral to repay it. For how a production pool implements supply, borrow and withdraw, see our Aave V3 Pool.sol deep dive.
In our stand-in market, a borrower can take up to 80% of the oracle value of their collateral. If a liquidation recovers less than the debt, the rest is recorded as bad debt, and the lenders carry it.
So two things carry all the risk: the price the market believes, and how much collateral it lets in. Checks 1 and 4 are about the price. Checks 2, 3 and 5 are about how much gets in.

1. A price that never moves: hardcoded collateral prices

In November 2025, Stream Finance disclosed a $93M loss from an external fund manager, and its yield-bearing stablecoin, xUSD, lost most of its value. Some lending markets priced xUSD with a fixed number rather than the market, so loans that should have been liquidated weren't. About $285M of debt was linked to Stream across DeFi. Four months later, in the Resolv spillover, wstUSR was marked at $1.13 by hardcoded oracles while it traded near $0.63.
We use these for one lesson only: a constant is not a price.

The demo

Our stand-in oracle is a price the owner sets. Left alone, it is a hardcoded "fundamental" price:
1// contracts/src/PriceFeed.sol
2/// Left alone, it is a hardcoded "fundamental" price. Updated by a keeper, it tracks the market.
3contract PriceFeed {
4 address public immutable owner = msg.sender;
5 uint256 public price;
6
7 constructor(uint256 price_) { price = price_; }
8 // ...
9}
The market values collateral with it, and lends up to 80%:
1// contracts/src/LendingMarket.sol
2function valueOf(address u) public view returns (uint256) { return collateralOf(u) * oracle.price() / 1e18; }
3
4function borrow(uint256 amt) external {
5 _beforeBorrow();
6 require(debt[msg.sender] + amt <= valueOf(msg.sender) * LTV / 1e18, "undercollateralised");
7 debt[msg.sender] += amt;
8 totalDebt += amt;
9 loan.transfer(msg.sender, amt);
10}
We crash our test stablecoin to ten cents on our own pool. The oracle still says one dollar:
1market holds 1,000,000 tUSDC from lenders
2tUSDX · oracle $1.00 · pool $1.00
3$ sellers dump 2,162,278 tUSDX into the pool
4 pool price $0.10
5 oracle price $1.00 (unchanged)
6$ borrower supplies 1,125,000 tUSDX · worth $112,500 at the pool
7$ borrower → market.borrow(900,000 tUSDC)
8 ✓ borrowed · borrower holds 900,000 tUSDC
9POSTED $112,500 · TOOK 900,000 tUSDC

The fix

Keep your reference price if you want, but check it against a live market price before every borrow. More than 2% apart, and the market refuses to lend:
1// contracts/src/DepegGuardMarket.sol
2uint256 public constant MAX_DEV = 0.02e18; // 2%
3
4function _beforeBorrow() internal view override {
5 uint256 fundamental = oracle.price();
6 uint256 live = market.spotPrice();
7 uint256 diff = fundamental > live ? fundamental - live : live - fundamental;
8 require(diff * 1e18 / fundamental <= MAX_DEV, "price deviation");
9}
Same deposit, same borrow:
1market holds 1,000,000 tUSDC from lenders
2tUSDX · oracle $1.00 · pool $1.00
3$ sellers dump 2,162,278 tUSDX into the pool
4 pool price $0.10
5 oracle price $1.00 (unchanged)
6$ borrower supplies 1,125,000 tUSDX · worth $112,500 at the pool
7$ borrower → market.borrow(900,000 tUSDC)
8 ✗ reverted: price deviation
9REVERTED · lenders keep 1,000,000 tUSDC
The live price here is our own pool's spot price, which is enough for the demo. In production, the second price should be one an attacker can't move in a single transaction, such as a TWAP or an independent feed. The 2% is our demo's configuration, not an industry figure. We compare those oracle designs, and how each gets manipulated, in Oracle manipulation in DeFi.

2. No cap on a thin token: collateral bigger than its market

On 30 August 2026, Tectonic, a lending market on Cronos, was exploited through its own governance token, TONIC, which it accepted as collateral. TONIC had about $1.34M of liquidity on exchanges. The attacker inflated its value with a donation and a pump, then looped it 98 times in one transaction. About $120.4M was borrowed across nine markets. The chain rolled back 10,961 blocks, and $9.19M was never recovered.

The demo

Our version: THIN has $1.3M of liquidity on our pool, the market prices it from that pool, and there is no supply cap. The attacker donates 650,000 tUSDC to the pool, then loops: buy THIN, supply it, borrow the maximum.
1THIN pool liquidity $1,300,000 · THIN $1.00
2market lenders deposited 120,000,000 tUSDC
3$ attacker donates 650,000 tUSDC to the pool · pool.sync()
4 THIN $2.00
5$ loop: buy THIN → supply → borrow max
6 loop 1 · THIN $3.22 · collateral 137,879 · borrowed 355,385
7 loop 2 · THIN $4.76 · collateral 228,634 · borrowed 870,503
8 loop 3 · THIN $7.52 · collateral 314,749 · borrowed 1,893,096
9 loop 4 · THIN $14.86 · collateral 411,508 · borrowed 4,890,773
10 loop 5 · THIN $50.63 · collateral 520,810 · borrowed 21,094,592
11 loop 6 · THIN $612.21 · collateral 612,848 · borrowed 120,000,000
12selling all 612,848 THIN collateral into the pool would raise 21,444,592 tUSDC
13$1,300,000 OF LIQUIDITY · 120,000,000 BORROWED
14market cash left: 0 tUSDC
Six loops take every unit of lender cash. The collateral behind it would sell for about 21.4M. Nobody could ever sell it for 120M.

The fix

A supply cap on every collateral, sized to what you could actually sell in a crash, not to what the price says it is worth. In the demo, the cap is 500,000 THIN:
1// contracts/src/CappedMarket.sol
2function _beforeSupply(uint256 amt) internal view override {
3 require(totalSupplied + amt <= supplyCap, "supply cap");
4}
5
6function _afterSupply(uint256 amt) internal override { totalSupplied += amt; }
7function _afterSeize(uint256 amt) internal override { totalSupplied -= amt; }
Same loop:
1THIN pool liquidity $1,300,000 · THIN $1.00
2market lenders deposited 120,000,000 tUSDC
3supply cap: 500,000 THIN
4$ attacker donates 650,000 tUSDC to the pool · pool.sync()
5 THIN $2.00
6$ loop: buy THIN → supply → borrow max
7 loop 1 · THIN $3.22 · collateral 137,879 · borrowed 355,385
8 loop 2 · THIN $4.76 · collateral 228,634 · borrowed 870,503
9 loop 3 · THIN $7.52 · collateral 314,749 · borrowed 1,893,096
10 loop 4 · THIN $14.86 · collateral 411,508 · borrowed 4,890,773
11 loop 5: supply 109,302 THIN ✗ reverted: supply cap
12selling all 411,508 THIN collateral into the pool would raise 4,977,978 tUSDC
13CAP 500,000 THIN · LOOP REVERTED · BORROWED 4,890,773
14market cash left: 115,109,227 tUSDC
The loop stops at loop 5. The 4,890,773 already borrowed is covered by collateral that would sell for 4,977,978, and 115,109,227 of lender cash never leaves. The cap doesn't fix a manipulable price; it limits how much one bad price can cost.
Run a vault or a lending market? Send us your collateral list before you add the next asset. We will tell you where it breaks, and what it would take to make it safe. Request a collateral review →

3. One transfer, ×40: an exchange rate anyone can inflate

Part of the Tectonic inflation, in Certora's analysis, was a donation: TONIC sent straight to the market contract, which raised its exchange rate without minting anything.

The demo

Our base market sets the share exchange rate from its own token balance:
1// contracts/src/LendingMarket.sol
2/// Collateral per share, 18 decimals.
3function exchangeRate() public view virtual returns (uint256) {
4 if (totalShares == 0) return 1e18;
5 return collateral.balanceOf(address(this)) * 1e18 / totalShares;
6}
Anyone can send tokens to a contract. One plain transfer lifts the rate, and every share now counts as forty times the collateral:
1$ attacker supplies 1,000 THIN
2 shares 1,000 · exchange rate 1.0
3$ attacker transfers 39,000 THIN straight to the market (no supply call)
4 market THIN balance 40,000
5 exchange rate 40.0
6 each share now counts as 40.0 THIN of collateral
7ONE TRANSFER · RATE 1.0 → 40.0

The fix

Compute the rate from cash you tracked yourself, not from the token balance. A donation changes the balance, not your books:
1// contracts/src/TrackedCashMarket.sol
2function exchangeRate() public view override returns (uint256) {
3 if (totalShares == 0) return 1e18;
4 return cash * 1e18 / totalShares; // a donation changes the balance, not cash
5}
6
7function _afterSupply(uint256 amt) internal override { cash += amt; }
8function _afterSeize(uint256 amt) internal override { cash -= amt; }
1$ attacker supplies 1,000 THIN
2 shares 1,000 · exchange rate 1.0
3$ attacker transfers 39,000 THIN straight to the market (no supply call)
4 market THIN balance 40,000 · tracked cash 1,000
5 exchange rate 1.0
6 each share now counts as 1.0 THIN of collateral
7SAME TRANSFER · RATE STAYS 1.0
The same rule applies to any price per share another market reads as collateral, including an ERC-4626 vault's. We cover the empty-vault version of this attack in the ERC-4626 inflation attack.

4. Peg broken, still lending: no circuit breaker

The demo

This time the oracle does track the market. The peg slips to $0.96, 4% off, and our market keeps lending against it at full LTV. Then the peg breaks, and liquidation can't recover the loan:
1lenders deposited 1,000,000 tUSDC
2$ the peg slips
3 oracle: tUSDX $0.960
4$ borrower supplies 1,000,000 tUSDX · borrows 768,000 tUSDC
5 ✓ borrowed 768,000
6$ the peg breaks
7 oracle: tUSDX $0.128
8$ liquidator → market.liquidate(borrower)
9 liquidator repaid 128,000 · took all 1,000,000 tUSDX
10 bad debt 640,000 tUSDC
11BAD DEBT 640,000 tUSDC · LENDERS CARRY IT
The liquidator can only repay what the collateral is worth. The other 640,000 is bad debt, and the depositors eat it.

The fix

A depeg circuit breaker. When the collateral drifts more than 3% from its peg, new borrows against it stop. Liquidations are untouched:
1// contracts/src/BreakerMarket.sol
2uint256 public constant PEG = 1e18;
3uint256 public constant MAX_DEV = 0.03e18; // 3%
4
5function borrowPaused() public view returns (bool) {
6 uint256 p = oracle.price();
7 uint256 dev = p > PEG ? p - PEG : PEG - p;
8 return dev * 1e18 / PEG > MAX_DEV;
9}
10
11function _beforeBorrow() internal view override {
12 if (borrowPaused()) revert("borrow paused: depeg");
13}
1lenders deposited 1,000,000 tUSDC
2$ the peg slips
3 oracle: tUSDX $0.960 · borrow paused: true
4$ borrower supplies 1,000,000 tUSDX · borrows 768,000 tUSDC
5 ✗ reverted: borrow paused: depeg
6$ the peg breaks
7 oracle: tUSDX $0.128 · borrow paused: true
8 no loan against tUSDX to liquidate
9 bad debt 0 tUSDC
10BORROWING PAUSED · BAD DEBT 0
The breaker only works if the price feeding it moves. Behind a hardcoded oracle from check 1, it never trips. The 3% is our demo's configuration; set yours from how far your collateral normally wanders from its peg.

5. Cap raised, same block: a curator who moves instantly

The demo

In a curator vault, the curator decides where deposits go. In our stand-in, a cap change takes effect at once:
1// contracts/src/CuratorVault.sol
2function setCap(address market, uint256 newCap) public virtual onlyCurator {
3 cap[market] = newCap;
4}
5
6function allocate(address market, uint256 amt) external onlyCurator {
7 require(allocated[market] + amt <= cap[market], "above cap");
8 allocated[market] += amt;
9 asset.transfer(market, amt);
10}
The curator raises one market's cap from 1,000,000 to 50,000,000, and moves the money in the same block. Depositors had no time to see it, and no time to leave:
1vault holds 60,000,000 tUSDC from depositors
2cap on risky market 0x0000…0b0b: 1,000,000
3$ curator → setCap(risky, 50,000,000) · allocate(risky, 50,000,000)
4 setCap block 7 · success
5 allocate block 7 · success
6 cap now 50,000,000
7CAP RAISED AND 50,000,000 MOVED · SAME BLOCK 7

The fix

Every cap increase, and every new collateral, waits behind a timelock. Three days in the demo. Cuts still apply at once:
1// contracts/src/TimelockedVault.sol
2uint256 public constant TIMELOCK = 3 days;
3
4function setCap(address market, uint256 newCap) public override onlyCurator {
5 if (newCap <= cap[market]) { cap[market] = newCap; return; }
6 pendingCap[market] = Pending(newCap, block.timestamp + TIMELOCK);
7}
8
9function acceptCap(address market) external {
10 Pending memory p = pendingCap[market];
11 require(p.validAt != 0 && block.timestamp >= p.validAt, "timelocked");
12 cap[market] = p.cap;
13 delete pendingCap[market];
14}
Same change:
1vault holds 60,000,000 tUSDC from depositors
2cap on risky market 0x0000…0b0b: 1,000,000
3$ curator → setCap(risky, 50,000,000) · allocate(risky, 50,000,000)
4 setCap block 9 · success
5 allocate block 9 · reverted: above cap
6 cap now 1,000,000
7 pending cap 50,000,000 · executable in 3 days
8PENDING 3 DAYS · 0 MOVED · DEPOSITORS CAN LEAVE
Depositors see the change coming, and can withdraw first. A timelock only helps if someone is watching, so emit an event for every pending change and surface it where depositors look.

The checklist

Screenshot this one.
  1. No constant prices on collateral. A market price, with a deviation guard.
  2. Supply caps sized to real exit liquidity.
  3. Exchange rates from internal accounting, never the raw token balance.
  4. A depeg circuit breaker that stops new borrows.
  5. Timelocks on cap increases and new collateral.
Then do the one thing from the introduction. Open your oracle config and write down the price source for every collateral. If any of them is a constant, or a number that never moves, that is check number 1. Fix that first.

FAQ

1. Why is a hardcoded oracle dangerous for a lending vault?
Because the market keeps lending, and keeps refusing to liquidate, against a price nobody can sell for. Around the Stream Finance collapse in November 2025, some lending markets priced xUSD with a fixed number, so positions that should have been liquidated weren't, and about $285M of debt was linked to Stream across DeFi. In our demo, a stablecoin crashed to $0.10 on our pool while the oracle still said $1.00, and $112,500 of collateral took 900,000 tUSDC. Checking the reference price against a live market price, with a 2% limit, made the same borrow revert.
2. How big should a supply cap be?
Size it to what you could actually sell in a crash, not to what the price says the collateral is worth. Tectonic's TONIC had about $1.34M of exchange liquidity, and about $120.4M was borrowed against it after a donation, a pump and 98 loops. In our demo, a token with $1.3M of liquidity and no cap backed 120,000,000 of borrows against collateral that would sell for about 21.4M. A 500,000-token cap stopped the loop at loop 5 with 4,890,773 borrowed.
3. How does a donation inflate a lending market's exchange rate?
If the market computes its exchange rate as its token balance divided by its shares, anyone can transfer tokens straight to the contract and raise the rate without minting a share. In our demo, one transfer of 39,000 tokens moved the rate from 1.0 to 40.0, so every share counted as forty times the collateral. Computing the rate from cash the market tracked on supply, not from balanceOf, kept it at 1.0.
4. What should a depeg circuit breaker stop?
New borrows against the depegging collateral, and nothing that helps the market get repaid. In our demo, a market kept lending at $0.96 and ended with 640,000 tUSDC of bad debt after the peg broke to $0.128. With new borrows paused beyond 3% off peg, the loan was never made and bad debt stayed at 0. Leave liquidations running, and feed the breaker a price that actually moves.
5. Why put a timelock on vault curator actions?
So depositors can see a risk change before their money moves, and leave if they disagree. In our demo, a curator raised a market's cap from 1,000,000 to 50,000,000 and allocated 50,000,000 in the same block. With a 3-day timelock on cap increases and new collateral, the allocation reverted, nothing moved, and the change sat pending where depositors could see it. Let cap cuts apply at once.

Glossary

TermDefinition
Bad DebtDebt left in a lending market after the collateral behind it has been seized and sold, and it still was not enough. Lenders carry it.
DepegWhen a token meant to hold a fixed price, such as a stablecoin at $1, trades away from it.
Supply CapThe most of a given collateral a lending market will accept, sized to what could be sold in a crash.
ERC-4626Tokenized vault standard providing a unified API for yield-bearing vaults with deposit, withdrawal, and accounting functions.
Donation AttackAn exploit where a direct token transfer inflates a vault's or market's share price.
Circuit BreakerAn emergency mechanism that halts protocol operations when anomalous conditions are detected.
Price Per ShareA vault's exchange rate between one share and the underlying asset.

Build it safely

Every attack and fix in this article is runnable on a local chain: ZealynxSecurity/zealynx-demos, newtech-08-vault. Clone it, run each demo vulnerable and then with --fixed, and compare them with your own market and vault. The contracts there are minimal teaching stand-ins, not any real protocol's code, and they say nothing about who was at fault in any real incident.
Run a vault or a lending market? Send us your collateral list before you add the next asset. We will tell you where it breaks, and what it would take to make it safe. Request a collateral review →

Working auditors in your corner, all year

Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.

Carlos (Bloqarl)
Written by

Founder at Zealynx

Keep reading
Flash loan attacks: anatomy of nine-figure DeFi exploits
Attack vectorsApr 29

Flash loan attacks: anatomy of nine-figure DeFi exploits

37 min read
ERC-4626 inflation attack: how it works and how to stop it
Attack vectorsJul 2

ERC-4626 inflation attack: how it works and how to stop it

20 min read
DAO governance attacks: how flash loans and vote manipulation drain treasuries
Attack vectorsMar 23

DAO governance attacks: how flash loans and vote manipulation drain treasuries

21 min read