
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.sol2/// 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;67 constructor(uint256 price_) { price = price_; }8 // ...9}
The market values collateral with it, and lends up to 80%:
1// contracts/src/LendingMarket.sol2function valueOf(address u) public view returns (uint256) { return collateralOf(u) * oracle.price() / 1e18; }34function 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 lenders2tUSDX · oracle $1.00 · pool $1.003$ sellers dump 2,162,278 tUSDX into the pool4 pool price $0.105 oracle price $1.00 (unchanged)6$ borrower supplies 1,125,000 tUSDX · worth $112,500 at the pool7$ borrower → market.borrow(900,000 tUSDC)8 ✓ borrowed · borrower holds 900,000 tUSDC9POSTED $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.sol2uint256 public constant MAX_DEV = 0.02e18; // 2%34function _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 lenders2tUSDX · oracle $1.00 · pool $1.003$ sellers dump 2,162,278 tUSDX into the pool4 pool price $0.105 oracle price $1.00 (unchanged)6$ borrower supplies 1,125,000 tUSDX · worth $112,500 at the pool7$ borrower → market.borrow(900,000 tUSDC)8 ✗ reverted: price deviation9REVERTED · 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.002market lenders deposited 120,000,000 tUSDC3$ attacker donates 650,000 tUSDC to the pool · pool.sync()4 THIN $2.005$ loop: buy THIN → supply → borrow max6 loop 1 · THIN $3.22 · collateral 137,879 · borrowed 355,3857 loop 2 · THIN $4.76 · collateral 228,634 · borrowed 870,5038 loop 3 · THIN $7.52 · collateral 314,749 · borrowed 1,893,0969 loop 4 · THIN $14.86 · collateral 411,508 · borrowed 4,890,77310 loop 5 · THIN $50.63 · collateral 520,810 · borrowed 21,094,59211 loop 6 · THIN $612.21 · collateral 612,848 · borrowed 120,000,00012selling all 612,848 THIN collateral into the pool would raise 21,444,592 tUSDC13$1,300,000 OF LIQUIDITY · 120,000,000 BORROWED14market 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.sol2function _beforeSupply(uint256 amt) internal view override {3 require(totalSupplied + amt <= supplyCap, "supply cap");4}56function _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.002market lenders deposited 120,000,000 tUSDC3supply cap: 500,000 THIN4$ attacker donates 650,000 tUSDC to the pool · pool.sync()5 THIN $2.006$ loop: buy THIN → supply → borrow max7 loop 1 · THIN $3.22 · collateral 137,879 · borrowed 355,3858 loop 2 · THIN $4.76 · collateral 228,634 · borrowed 870,5039 loop 3 · THIN $7.52 · collateral 314,749 · borrowed 1,893,09610 loop 4 · THIN $14.86 · collateral 411,508 · borrowed 4,890,77311 loop 5: supply 109,302 THIN ✗ reverted: supply cap12selling all 411,508 THIN collateral into the pool would raise 4,977,978 tUSDC13CAP 500,000 THIN · LOOP REVERTED · BORROWED 4,890,77314market 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.sol2/// 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 THIN2 shares 1,000 · exchange rate 1.03$ attacker transfers 39,000 THIN straight to the market (no supply call)4 market THIN balance 40,0005 exchange rate 40.06 each share now counts as 40.0 THIN of collateral7ONE 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.sol2function exchangeRate() public view override returns (uint256) {3 if (totalShares == 0) return 1e18;4 return cash * 1e18 / totalShares; // a donation changes the balance, not cash5}67function _afterSupply(uint256 amt) internal override { cash += amt; }8function _afterSeize(uint256 amt) internal override { cash -= amt; }
1$ attacker supplies 1,000 THIN2 shares 1,000 · exchange rate 1.03$ attacker transfers 39,000 THIN straight to the market (no supply call)4 market THIN balance 40,000 · tracked cash 1,0005 exchange rate 1.06 each share now counts as 1.0 THIN of collateral7SAME 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 tUSDC2$ the peg slips3 oracle: tUSDX $0.9604$ borrower supplies 1,000,000 tUSDX · borrows 768,000 tUSDC5 ✓ borrowed 768,0006$ the peg breaks7 oracle: tUSDX $0.1288$ liquidator → market.liquidate(borrower)9 liquidator repaid 128,000 · took all 1,000,000 tUSDX10 bad debt 640,000 tUSDC11BAD 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.sol2uint256 public constant PEG = 1e18;3uint256 public constant MAX_DEV = 0.03e18; // 3%45function 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}1011function _beforeBorrow() internal view override {12 if (borrowPaused()) revert("borrow paused: depeg");13}
1lenders deposited 1,000,000 tUSDC2$ the peg slips3 oracle: tUSDX $0.960 · borrow paused: true4$ borrower supplies 1,000,000 tUSDX · borrows 768,000 tUSDC5 ✗ reverted: borrow paused: depeg6$ the peg breaks7 oracle: tUSDX $0.128 · borrow paused: true8 no loan against tUSDX to liquidate9 bad debt 0 tUSDC10BORROWING 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.sol2function setCap(address market, uint256 newCap) public virtual onlyCurator {3 cap[market] = newCap;4}56function 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 depositors2cap on risky market 0x0000…0b0b: 1,000,0003$ curator → setCap(risky, 50,000,000) · allocate(risky, 50,000,000)4 setCap block 7 · success5 allocate block 7 · success6 cap now 50,000,0007CAP 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.sol2uint256 public constant TIMELOCK = 3 days;34function 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}89function 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 depositors2cap on risky market 0x0000…0b0b: 1,000,0003$ curator → setCap(risky, 50,000,000) · allocate(risky, 50,000,000)4 setCap block 9 · success5 allocate block 9 · reverted: above cap6 cap now 1,000,0007 pending cap 50,000,000 · executable in 3 days8PENDING 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.
- No constant prices on collateral. A market price, with a deviation guard.
- Supply caps sized to real exit liquidity.
- Exchange rates from internal accounting, never the raw token balance.
- A depeg circuit breaker that stops new borrows.
- 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
| Term | Definition |
|---|---|
| Bad Debt | Debt left in a lending market after the collateral behind it has been seized and sold, and it still was not enough. Lenders carry it. |
| Depeg | When a token meant to hold a fixed price, such as a stablecoin at $1, trades away from it. |
| Supply Cap | The most of a given collateral a lending market will accept, sized to what could be sold in a crash. |
| ERC-4626 | Tokenized vault standard providing a unified API for yield-bearing vaults with deposit, withdrawal, and accounting functions. |
| Donation Attack | An exploit where a direct token transfer inflates a vault's or market's share price. |
| Circuit Breaker | An emergency mechanism that halts protocol operations when anomalous conditions are detected. |
| Price Per Share | A 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.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →
Keep reading


