Freezing a six-figure crypto vault
A few percent of disagreement between two contracts can freeze every deposit and withdrawal in a live crypto vault — and this one went unnoticed for six weeks.
Original vulnerability research from the team building Trident.
- pkgconf
- defi
- cisco
- meta
- Published
- Reading time
- 10 min read
- Category
- Threat Research
IN THIS POST · 8 sections
Most security bugs are a story about someone getting in. This one is the opposite: it’s about everyone getting locked out. No attacker, no stolen keys, no clever exploit — just two pieces of code that each looked perfectly fine on their own, and a small disagreement between them that, on an ordinary day, could freeze a whole vault full of other people’s money.
We ran into it while auditing a live DeFi vault — the kind of contract that takes deposits, earns yield, and pays it back out. Buried in its plumbing, one contract was allowed to produce a number that a second contract, sitting right next to it, was built to reject. Each was reasonable in isolation. Put together, they meant an unremarkable dip in the market could make every deposit and every withdrawal start failing at once. And it had been sitting there, live and quietly exposed, for about six weeks.
That is exactly what makes it worth walking through. There is no daring exploit to admire and no villain to point at — only a gap of a few percent between two honest-looking pieces of code. It is the kind of flaw that walks straight past a normal review, precisely because nothing about it looks wrong. So let’s take it apart, piece by piece.
The setup
A little context first. This is a risk-tranching vault: it splits depositors into two groups — a senior tranche that takes less risk for a steadier return, and a junior tranche that takes on more risk for more upside. To divide the yield fairly between them, the vault has to know one number at all times: the current interest rate, or APR.
That number comes from a component called a rate feed — think of it as the vault’s thermometer for yield. It works two ways. Most of the time, a trusted operator (a “keeper”) posts a fresh reading on-chain and the feed simply hands that back. But if there is no recent reading, the feed falls back to calculating one itself, on the spot, from a helper contract called a provider — and before it trusts that freshly computed number, it runs a quick sanity check:
A couple of things to read off that snippet. Rates are stored as signed, 12-decimal fixed-point numbers, so 1e12 means 100% and -0.5e12 means -50%. The number is allowed to go negative on purpose: if the underlying asset lost value over the period, the yield really was negative for that stretch, and the vault is built to cope with it. Taken on its own, ensureValid is exactly the guardrail you would want — it quietly refuses anything absurd. The trouble only shows up when you ask where its limits came from.
Where the two contracts disagree
The number ensureValid is checking doesn’t come from nowhere. The provider works it out from how much the asset’s price-per-share moved between two oracle readings. Since those readings are only a short time apart, it annualizes the move — scaling a short-term change up to a yearly figure, the same way a 0.1%-a-day drift might get quoted as “about 36% a year.” Then, just like the feed, it applies its own bounds before handing the number over:
And there it is, hiding in plain sight. The provider will happily return anything from -100% to +100%, zeroing out only the truly wild values. The feed, meanwhile, only accepts -50% to +200%. For most readings those two ranges overlap and everyone is happy — but look at the bottom end. The provider’s floor is -100%; the feed’s floor is only -50%. There is a gap between them, and any number that falls into it is legal on one side of the handshake and forbidden on the other.
Why one bad round freezes the vault
A disagreement like this would be harmless if the rejection happened somewhere quiet — an admin-only setting, say. It doesn’t. The check lives inside the feed’s latestRoundData(), the function that reports the current rate, and the vault’s accounting calls it on every single action that moves money — with no safety net (no try/catch) to soften a failure:
Follow the dominoes. The instant the annualized rate lands in that dead zone, ensureValid throws — which kills latestRoundData(), which kills the accounting update, which kills the user’s transaction. And because deposits, withdrawals, and redemptions all run through that same path, they stop working together, all at once — not one function, the entire vault. What turns a single rejected number into a frozen vault is one quiet design choice: the feed reverts instead of clamping. Its own downstream code already knows how to handle a too-low rate gracefully (it just floors it to zero) — but the feed slams the door before that code ever gets a say. The stricter contract wins, and it wins on the busiest path in the whole system.
How little it takes to trigger
Here is the part that should make a protocol designer uneasy: because the rate is annualized, the market move needed to hit that -50% floor is genuinely tiny. Over a single day, a drop of about 0.137% in the asset’s price-per-share is enough to annualize past the line. For a yield-bearing real-world asset or a staking token, a daily wobble that small — a fee, a routine revaluation, a minor hiccup — is completely ordinary. Nobody has to attack anything; the market trips the wire by itself, and the freeze is just the side effect.
There is a genuinely strange twist here, too. A truly catastrophic crash — worse than -100% in one round — gets clamped to zero by the provider and passes the feed without complaint. It is only the moderate, believable declines that produce a real negative number in the danger zone. Put plainly: the vault shrugs off the disaster and freezes on an ordinary bad Tuesday.
What actually breaks
So what does this look like for the people with money in the vault? While the asset sits in that band, the damage is total — every normal way in or out is closed:
- Every
deposit,withdraw,redeem, and cooldown finalization reverts. The ordinary doors are all locked. - The “break glass” options don’t help either: the privileged reserve and rescue functions run through the same accounting update, so they revert too. There is no admin lever that quietly moves funds to safety.
- The only real cure is a keeper pushing a fresh, in-range rate — which is exactly the thing least likely to go smoothly during the market stress that caused the freeze in the first place.
The freeze does lift by itself once the rate climbs back above -50%, so on paper it is only “temporary.” But temporary here means it switches on at exactly the moment people most want out, and stays on until the market decides to cooperate. Everyone in the vault, senior and junior alike, is stuck at the same time.
In the vault we looked at, that was a low six-figure balance sitting behind the freeze — and the mismatch had already been live on-chain for roughly six weeks before anyone flagged it. Six weeks in which one unremarkable down day would have locked every depositor out of their own money.
The fix
The reassuring part is that the fix is genuinely small — the hard bit is noticing it is needed at all. There are two clean options. Either widen the feed’s accepted range so it covers everything the provider can legally return, or — the better choice — have the feed clamp an out-of-range value to its nearest limit instead of rejecting it outright, which is exactly what the code downstream already does with it:
While you are in there, check the ceiling as well — the very same bug can hide on the upside from a single typo. A provider whose maximum is written as 200 * 1e12 (that is, 2e14) paired with a feed that caps at 2e12 is off by a factor of a hundred, and it would freeze the vault on a big positive rate instead of a negative one.
Why a normal review misses it
It is worth sitting with why something this consequential is so easy to walk past. Every file here passes its own review with flying colors. ensureValid is a textbook bounds check; getRate is a sensible annualization with sensible guards. The bug is not inside either contract — it is in the handshake between them, and a reviewer reading one file at a time simply never sees both floors next to each other.
The mental model that catches it: a numeric range is a promise between two pieces of code. If one contract can produce values across some range, then everything that consumes those values has to accept that same range — or make a deliberate decision about the difference. And these promises drift most easily at the boundaries that never show up in a function signature: the exact minimum and maximum, the number of decimals, whether negatives are allowed, the units. When they drift, the stricter side quietly turns a legal value into a crash.
Two habits catch it in practice. First, whenever a value crosses from one contract to another, line up the assumptions on both sides — the ranges, the scaling, the sign — not just the logic. Second, fuzz the edges: aim tests squarely at the line between what a function accepts and what it rejects, because tests built around the values you expect will never wander into the narrow band where a legal number suddenly isn’t.
Reproducing it
If you want to watch it break for yourself, here is a small, self-contained Foundry test. It builds a stripped-down provider and feed with the same mismatched floors, plus a stand-in vault whose withdrawal reads the feed. A flat, boring round sails through; a single ordinary down-round pushes the computed rate into the dead zone, and the read that every withdrawal depends on reverts — freezing this toy vault exactly the way the real one would.
Thanks for reading.Trident Research, the team building Trident · Jul 11, 2026




