NAV Is a Control Input, Not a Dashboard Number
· 14 min read
- architecture
- rwa
- oracles
- solidity
NAV Is a Control Input, Not a Dashboard Number
Centrifuge's core price type is a struct and one function. The struct holds a value and the timestamp it was computed at. The function decides whether that value may be used, and the age of the timestamp takes no part. In Centrifuge's core a price never expires, and it counts as valid the moment computedAt != 0. Centrifuge Price.sol
The age is recorded and nothing in core reads it.
That line is drawn in the right place, though the reflex is to call it a bug. Core keeps the fact. The judgment about how old a net asset value may be before a contract acts on it belongs one layer out, in a policy contract a pool operator configures. The split makes visible that somebody has to make that judgment, and that it isn't about display.
A net asset value in a tokenized fund is what its holdings are worth, divided across its shares, and it travels two directions at once. One copy goes to a dashboard, where a person reads it and forms an opinion. The other goes into a contract, where a subscription is priced, a redemption is settled, and a lending market decides what a share is worth as collateral. Same scalar, two jobs, and only the second one moves money without asking anybody first.
The same number does two jobs, and only one of them can lose money
A net asset value that mint, redeem and collateral logic read is a control input, and the question a control input answers is what the system does when the number is wrong.
For most numbers a DeFi contract reads the answer is not a mechanism at all. Take a spot price from an automated market maker. Push it away from the truth and you've created a trade that pays somebody to push it back, so the error carries its own repair. You still bound it, because the window before somebody notices is worth money, but you're bounding something that corrects itself.
A fund's net asset value does have gravity, and it isn't the kind that corrects it. Fund shares are minted and redeemed at the published number, and lending markets liquidate against oracles whose description string is the fund's name followed by the word NAV. None of that votes on whether the number's right, only on whether it's redeemable.
So the discriminator is one question, and its direction is the whole point: who profits from moving this value back toward the truth? For an AMM price, the arbitrageur, because there the price is the value. For a NAV struck once a day by an administrator, nobody. The regulator who wrote the exchange traded fund rule says which way the correction runs when the mechanism is working: arbitrage moves the market price, in the rule's phrase, to a level at or close to the NAV. the SEC exchange traded fund adopting release A wrong NAV buys a fill rather than a repair, and Aave's real world asset documentation names the window and calls the party standing in it a whitelisted liquidator who could exploit a faulty price. Aave Horizon documentation, RWA price oracle safeguards Ask it of every external number your safety logic reads; the ones with nobody on the correcting side need a bound on the value, not on the writer.
The standards already know this, and one of them says so in normative text. ERC-7540 requires a vault that takes deposit requests asynchronously to revert previewDeposit and previewMint for all callers and inputs. ERC-7540 specification About the request functions the same standard says why they return nothing: a request may not have a known exchange rate when it is claimed. The reversion mandate itself is written per flow, not per vault. Underneath it sits ERC-4626, which every ERC-7540 vault has to implement. ERC-4626 requires convertToShares to stay readable and forbids it to revert except on integer overflow. ERC-4626 specification
Read those two together and the subject of this piece is in the standards' own hands: the number a caller can read survives, the number a caller can act on is taken away.
The strongest case for bounding the writer and leaving the number alone
Here's the position I have to beat, at its best.
The serious alternative is to bound the writer instead of the value, because the writer is a named accountable party and any bound on the value is a guess about the world made before the world moves.
Centrifuge ships that position as a legal configuration of its own policy: set both the step bound and the rate bound to zero and the guard returns instantly on every move. Centrifuge StdHubPolicy.sol, the guard's disable arm The base case underneath has the same shape. Its price manager checks that the caller is the configured updater and then recomputes the share price from the pool's accumulated value. Centrifuge SimplePriceManager.sol Nothing in that function caps how far the value may move.
The case gets stronger the moment you try to write the bound down. What's a legitimate one day move for a tokenized treasury fund? Small, until a rate decision. For a private credit pool? Small, until a borrower defaults and the manager writes the position down by a third in one publication. A bound tight enough to catch a mis keyed decimal will one day refuse the truth on the worst possible day, and refusing the truth in a redemption queue has a name: a fund that cannot mark itself down while people redeem at the old price.
So it deserves to fail on a specific point rather than on a shrug. Authorization answers one question: was this written by the party allowed to write it? Take the four failures most worth designing against; all four answer yes. A fat finger is written by the right party, so is a decimal shifted by a broken adapter, so is a key that leaked last Tuesday, and so is a feed that stopped updating and served its last value.
Three of those are moves. The fourth is the absence of one, and a bound on how far a value may move is blind to a value that does not move.
The permission bound does real work. It's doing a different job from the one these four failures need.
Bound the value, and make the failure arm a delay
Bound the update at the value, measure the bound against the last executed update, and make the failure arm a delay with a veto window rather than a revert.
Centrifuge bounds a share price move one layer up, by an absolute step and by a rate measured against the last executed update. Centrifuge StdHubPolicy.sol A move inside both bounds runs in the same transaction. A move outside either returns a delay: pre authorize it, let it mature, survive the sentinel's veto window.
That is what most designs get wrong, and it's why I wouldn't reach for a revert. A revert throws the legitimate move away and leaves the operator holding a fund they can't mark. A delay keeps it, prices it in hours instead of blocks, and buys the one thing that helps: a person looking at a number that surprised a machine.
It also answers the objection above: a bound written before the world moves will sometimes be wrong, and if that costs a timelock rather than a refusal you can set it tighter.
Here's the whole path, from the administrator to the state change.
fund administrator
|
v
NAV published offchain, discrete, no market to correct it
|
v
+---------------------+
| bound at the VALUE | step bound and rate bound, both measured
| | from the last EXECUTED update
+---------------------+
| |
| inside | outside
v v
instant delay -> pre authorize -> sentinel window -> execute
|
v
share price -> mint price, redeem price, collateral value
|
v
invariant: price times issuance recovers the NAV it came from, up to roundingFour details in that shape carry weight, and I hold two of them only because the gate below exists and the test disagreed with me.
The first is measuring from the last executed update. Anchor on proposals and a caller walks the price anywhere by a staircase; anchor on executions and every step pays elapsed time.
The second is what happens when no time has passed. Zero elapsed can't be rate bounded, because the rate divides by it, so a same block move goes out of policy. That clause is in the gate below and I went after it: split a breaching move into sub steps and every one after the first lands in the same block.
Role: reduced executable model, the complete gate.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
uint8 constant INSTANT = 0;
uint8 constant DELAYED = 1;
/// Reduced NAV gate. `last` is the price the last EXECUTED update committed.
function classify(uint256 last, uint256 next, uint256 elapsed, uint256 maxStep, uint256 maxRate)
pure
returns (uint8)
{
if (maxStep == 0 && maxRate == 0) return INSTANT;
uint256 delta = next > last ? next - last : last - next;
if (delta == 0) return INSTANT;
if (elapsed == 0) return DELAYED;
if (maxStep != 0 && delta >= maxStep) return DELAYED;
if (maxRate != 0 && delta / elapsed >= maxRate) return DELAYED;
return INSTANT;
}The third detail is the one I had wrong, and the test is what told me. My boundary case asserted that a hair under the step bound runs instantly. In the harness the step bound is one percent of the price and the rate bound allows a little under one percent a day, and at those two numbers one wei under the step bound still trips the rate bound over a day, so passing one guard does not mean the update runs instantly. Two guards on one value interact, and the interaction appears in neither definition on its own.
The gate returns on every input the fuzzer reached and reverts on none. A bound that can revert can brick the fund, and an operator who believes that will switch it off. The disable arm is in the code, both bounds at zero, and it's there because it's in Centrifuge's policy too. That is the alternative from the section before, surviving as a setting rather than a default, and I'd rather an operator had the switch than argued the guard away.
The fourth detail is the one the shape above does not have, and it is the failure I promised to come back to. A feed that stops updating and keeps serving its last value produces a delta of zero, and a delta of zero passes the move bound printed above, and every bound shaped like it. The comment on Centrifuge's own share price guard says computedAt is ignored. Centrifuge StdHubPolicy.sol, the doc comment on _checkSharePrice So the age core recorded and declined to enforce is not enforced one layer up. A move bound and an age bound are two instruments, and shipping the first and calling it freshness confuses a number that jumped with one that stopped.
Two carve outs ride in that comment. The first update per share class under a policy instance runs unguarded, with no committed baseline yet. Committing the price that already stands skips the guard too, because no price move exists to bound. Both would surprise an operator reading the bound as unconditional. And the guard ships in the source while appearing nowhere under the repository's script and deployment directories. Elsewhere the same idea is wired and running: Aave, Chainlink and LlamaRisk configure per asset bounds that the oracle network checks before a NAV can reach the pool at all. Aave Horizon documentation, RWA price oracle safeguards Their arm is refusal rather than delay, and the last valid price stands, which is the trade I argued against. The no move commit does move something, though. A third property rides in the anchoring code beside the guard: after a policy change anyone can permissionlessly commit the price already standing, which moves the baseline to that block and narrows the deviation the next real move is allowed.
Then detection, which is the half no bound covers.
Bounding the value catches the moves you can describe in advance, and not the one you didn't. Something I shipped went wrong with real money in it, and someone outside told me before my own alerts did.
The repair is a different source for the alarm rather than a longer list of symptoms: derive it from an invariant the system has to satisfy instead of from a failure mode somebody imagined in advance. A tokenized fund has one in plain sight: the share price the contracts use, multiplied by the shares outstanding, has to recover the net asset value that price was computed from, to within the rounding the division discards. Reconcile the two on every executed update and halt on a divergence wider than that, and the alarm fires without anybody needing to know why. A move waiting out its delay is a declared exception.
What the bound costs, and who waits
The bound costs the fund administrator a timelock on the day the portfolio moves most, and it costs the redeeming holder a price that is one delay period behind.
Those are one cost seen from both ends, and they land on the day you'd least choose. A rate decision moves a treasury book, a default moves a credit book, and the move that trips the guard is by construction the move everybody is watching.
The pool manager pays in another currency: two numbers guessing at a portfolio that changes underneath them, and nothing says when they've gone stale.
The sentinel pays with attention. The veto window is the only part of this design needing a person, and a window nobody watch is a delay. I'd rather ship a short window somebody staffs than a long one that reads well.
This section skips who that sentinel should be on purpose: that's governance rather than mechanism, answered per fund and never per protocol, and a familar shape borrowed from another fund's charter is worth less than one sentence naming the person on call.
When the bound is on the wrong number
The bound belongs on a different scalar as soon as the value the state transition reads is not the value the fund publishes.
The clean case is a money market fund that pins its share price at one unit and pays yield by changing the share count. Everything above still applies, because nobody's paid to correct its numbers either. But a step bound on a price that never moves is inert, and sits green while the rebase factor does the damage. The discriminator was right about the fund and wrong about the scalar.
The guard cannot rescue itself here: Centrifuge's policy decodes a share price out of the payload and bounds that, and a value it doesn't decode it never sees. So the rule is a question about the transition, not the fund: find it, read which value it multiplies by, and bound that. In a price per share vault it's the price. In a rebasing token it's the rebase factor. In a lending market holding the share as collateral it may be neither, because the market applies its own haircut and the bound that matters is on the haircut input.
Bound the number your state transitions read, at the value and not only at the writer, whenever no actor is paid to correct it.
What this piece does not establish
Written on 2026-09-06 from public sources and one reduced harness. No live tokenized fund was operated, no production NAV pipeline measured. In this reduced model the share price is a bare number and the gate holds no state. Centrifuge ships tests for its own guard and I didn't run them. How often a published NAV is wrong in production stays unmeasured.
What I have operated myself is a divergence breaker on a settlement venue price oracle, and it triggered twice during testnet. That is the whole of my standing on operating one; the incident above is a detection story and not an operating one. The transfer to a fund NAV pipeline is bounded to the property the two share: an externally computed scalar that gates onchain state transitions. Two properties they do not share are the ones this piece turns on. An oracle price has a market behind it and a fund NAV does not, and a divergence breaker compares two sources, which a fund NAV cannot borrow because the second source is the thing that is missing.
This piece does not establish what a correct bound is for any particular fund, and nothing here was run against a live tokenized fund.
Abdel KIARI
I’ve owned the EVM side of a DeFi protocol with $110M+ in funding, including backing from Coinbase Ventures. I redesigned its architecture from scratch and built the Solidity infrastructure end to end.
Always happy to talk about interesting opportunities
abdel.kiari@gmail.com