Core Formulas¶
All pricing, NAV, gap, and cap formulas used by the Vault-K-NO protocol.
6.1 Per-Position Modeled Value¶
if position.status == WRITTEN_OFF || position.status == EMPTY:
return 0
if position.status == SETTLING:
return adapter.positionValue() // market value — accrual frozen, gap = 0 for this slot
// status == ACTIVE: linear accrual
elapsed = now - position.startTime
duration = position.maturity - position.startTime
accrualRate = min(elapsed / duration, 1.0)
modeledPrice = entryPrice + (1e18 - entryPrice) × accrualRate
modeledValue = modeledPrice × adapter.positionSize() / 1e18
Interpretation: ACTIVE slots accrue linearly from entry price toward $1.00. SETTLING slots use market value directly — the moment markSettling() is called, the gap contribution of that slot collapses to zero and phantom accrual stops.
6.2 Per-Position Market Value¶
if position.status == ACTIVE || position.status == SETTLING:
return adapter.positionValue() // positionSize × currentPrice
return 0
Interpretation: Both ACTIVE and SETTLING slots contribute their live market price × position size. WRITTEN_OFF and EMPTY slots contribute zero.
6.3 Aggregate NAVs¶
aggModeledNAV = Σ positionModeledValue(i) + idleReserve // i in [0..3]
// ACTIVE: linear accrual value
// SETTLING: adapter.positionValue() — gap = 0 for this slot
// WRITTEN_OFF, EMPTY: 0
aggMarketNAV = Σ positionMarketValue(i) + idleReserve // i in [0..3]
// ACTIVE or SETTLING: adapter.positionValue()
// WRITTEN_OFF, EMPTY: 0
Idle USDC (in idleReserve) contributes equally to both NAVs — it creates no gap. SETTLING slots contribute the same value to both modeled and market NAV, so they also create no gap.
6.4 Exit Price Curve¶
Integer arithmetic: fill must be 1e18 fixed-point
In integer arithmetic, fill must be computed as redeemedToday * 1e18 / dailyCap. Writing fill = redeemedToday / dailyCap (without scaling) produces 0 for all partial fills because both values are USDC (6 dec). This silently collapses the curve to aggMarketNAV on every withdrawal with no error.
// Conceptual formula (real-valued):
fill = redeemedToday / dailyCap // in [0.0, 1.0]
exitNAV = aggMarketNAV + (aggModeledNAV - aggMarketNAV) × (1 - fill)²
// Integer arithmetic (mandatory):
fill = redeemedToday * 1e18 / dailyCap // in [0, 1e18]
exitNAV is the curve-weighted aggregate vault NAV (USDC, 6 dec) — a total vault valuation, not a per-share price. To convert to USDC owed for a redemption:
exitValue = shares * exitNAV / totalShares // correct
// NOT: shares * exitNAV / 1e18 // wrong — exitNAV is not a per-share price
| fill | (1-fill)² | exitNAV |
|---|---|---|
| 0.0 | 1.00 | aggModeledNAV (best) |
| 0.25 | 0.5625 | 56.25% of the spread above market |
| 0.5 | 0.25 | 25% of the spread above market |
| 0.75 | 0.0625 | 6.25% of the spread above market |
| 1.0 | 0.00 | aggMarketNAV (worst) |
6.5 Average Curve NAV — Closed-Form Derivation¶
Return value is a total vault NAV, not a per-share price
_avgCurvePrice returns curveNAV — the curve-weighted aggregate vault NAV (USDC, 6 dec), a total vault valuation between aggMarketNAV and aggModeledNAV. It is not a per-share price. The only correct conversion to exitValue is shares * curveNAV / totalShares. Writing shares * curveNAV / 1e18 is wrong — 1e18 is not the correct divisor.
When a withdrawal request is processed, it advances fill from fillBefore to fillAfter (both 1e18 fixed-point). The investor receives the average curve-weighted vault NAV over that interval. A loop approximation is unacceptable: precision error varies with step size and creates consensus risk if the keeper implementation is replaced. The closed form must be used.
Derivation:
exitNAV(x) = mkt + (mdl - mkt) × (1 - x)²
avgNAV(a, b) = (1 / (b-a)) × ∫[a..b] exitNAV(x) dx
= mkt + (mdl - mkt) × (1 / (b-a)) × ∫[a..b] (1-x)² dx
Let u = 1-x, du = -dx:
∫[a..b] (1-x)² dx = [(1-a)³ - (1-b)³] / 3
Therefore:
avgNAV(a, b) = mkt + (mdl - mkt) × [(1-a)³ - (1-b)³] / [3 × (b-a)]
Returns: curveNAV (USDC, 6 dec) — total vault valuation.
exitValue = shares * curveNAV / totalShares
Integer arithmetic implementation (1e18 fixed-point):
// fillBefore, fillAfter: 1e18 fixed-point in [0, 1e18].
// MUST be pre-scaled by caller: fillBefore = redeemedToday * 1e18 / dailyCap
// mdl = aggModeledNAV (USDC, 6 dec)
// mkt = aggMarketNAV (USDC, 6 dec)
// Returns: curveNAV (USDC, 6 dec) — total vault valuation, NOT per-share price.
function _avgCurvePrice(
uint256 fillBefore, // [0, 1e18] — caller must scale: redeemedToday*1e18/dailyCap
uint256 fillAfter, // [0, 1e18] — caller must scale: (redeemedToday+reqVal)*1e18/dailyCap
uint256 mdl,
uint256 mkt
) internal pure returns (uint256 curveNAV) {
uint256 oneMinusA = 1e18 - fillBefore;
uint256 oneMinusB = 1e18 - fillAfter;
uint256 fillRange = fillAfter - fillBefore; // non-zero: enforced by caller
uint256 cubeA = oneMinusA * oneMinusA / 1e18 * oneMinusA / 1e18;
uint256 cubeB = oneMinusB * oneMinusB / 1e18 * oneMinusB / 1e18;
uint256 cubeDiff = cubeA - cubeB; // cubeA >= cubeB always
if (mdl <= mkt) return mkt; // no gap — return market NAV
uint256 gap = mdl - mkt;
// curveNAV = mkt + gap * cubeDiff / (3 * fillRange)
// Units: gap (USDC 6dec) * cubeDiff (1e18) / fillRange (1e18) = USDC 6dec. Correct.
curveNAV = mkt + gap * cubeDiff / (3 * fillRange);
}
// CALLER USAGE — the only correct way to convert curveNAV to exitValue:
// exitValue = request.shares * curveNAV / totalShares;
// NOT: request.shares * curveNAV / 1e18 (wrong divisor — curveNAV is not per-share)
fillRange must be non-zero
fillBefore and fillAfter must be pre-scaled to 1e18 by the caller. fillRange must be non-zero — the caller must ensure fillAfter > fillBefore before calling.
6.6 Share Pricing¶
sharePrice = aggModeledNAV / totalShares // investor-facing accrual price
marketSharePrice = aggMarketNAV / totalShares // true mark-to-market price
sharesPerDeposit = depositAssets × totalShares / aggModeledNAV
New investors buy shares at sharePrice (modeled basis). Redemptions are priced between marketSharePrice and sharePrice depending on daily fill.
6.7 Gap¶
gap = max(0, aggModeledNAV - aggMarketNAV)
// Division-by-zero guard: aggModeledNAV is zero only in degenerate state
// (all positions WRITTEN_OFF and idleReserve == 0). Define gapBps = 0.
gapBps = (aggModeledNAV == 0) ? 0 : gap × 10000 / aggModeledNAV
Gap can never be negative — aggMarketNAV exceeding aggModeledNAV does not produce a negative gap. This is a conservative design: if market prices exceed model, investors benefit at no cost to the system.
Pause triggers when: gapBps > 1500 (15%)
6.8 Daily Cap¶
Based on market NAV, not modeled NAV
The daily cap is based on aggMarketNAV — not aggModeledNAV. This prevents the cap from being artificially inflated by the optimistic model during stress periods. During a crisis when gap is wide, the daily cap is smaller (based on the depressed market NAV), providing additional protection.
6.9 Rebase Constraints¶
require(newEntryPrice <= _modeledPrice(position));
require(newEntryPrice >= adapter.currentPrice() || newEntryPrice == 0);
require(now >= position.lastRebase + REBASE_COOLDOWN || newEntryPrice == 0);
These constraints ensure rebases can only decrease modeled NAV (conservative direction) and cannot be executed more than once per 7 days per position (unless it's a zero write-off).
6.10 Arithmetic Precision Specification¶
All formulas use 1e18 fixed-point for share and price values, and 6-decimal fixed-point for USDC amounts, consistent with the USDC token contract.
First Deposit — totalShares == 0 Initialisation¶
On the first deposit, totalShares == 0 and aggModeledNAV == 0. The general formula shares = assets × totalShares / aggModeledNAV produces 0/0 and must not be used.
if totalShares == 0:
sharesToMint = assets × 1e12 // first-deposit initialisation rule
else:
sharesToMint = assets × totalShares / aggModeledNAV // floor
The 1e12 factor is an invariant
USDC has 6 decimals; shares use 18 decimals. The 1e12 factor aligns units and prevents dust-based share price manipulation. Any implementation that mints 1:1 without the scaling factor creates a share price of 1e-12 USDC, making the vault trivially vulnerable to first-depositor inflation attacks.
Truncation Direction¶
// Shares minted on deposit: floor — favours vault over depositor.
sharesToMint = assets * totalShares / aggModeledNAV // floor (Solidity default)
// Liquidity fee: ceiling — favours HouseBuffer over investor.
fee = (exitValue * liquidityFeeBps + 9999) / 10000 // ceiling
// Payout to investor: floor after fee deduction.
payout = exitValue - fee // floor implicit
Overflow Analysis¶
All intermediates are safe within uint256 at maximum projected vault TVL of $15M.
| Expression | Max value | uint256 safe? |
|---|---|---|
modeledPrice × positionSize |
1e18 × 2e24 = 2e42 |
Yes |
assets × totalShares |
15e12 × 1e24 = 1.5e37 |
Yes |
gap × cubeDiff |
15e12 × 1e18 = 1.5e31 |
Yes |
oneMinusA × oneMinusA (before /1e18) |
1e18 × 1e18 = 1e36 |
Yes |
gap × 10000 (gapBps intermediate) |
15e12 × 10000 = 1.5e17 |
Yes |
Re-evaluate if TVL exceeds $15M
This overflow analysis must be re-evaluated before deployment if vault TVL is projected to exceed $15M.
Formula Relationships¶
┌──── idleReserve
aggModeledNAV ──────┤
└──── Σ modeledValue(i) ── entryPrice + accrual
┌──── idleReserve
aggMarketNAV ──────┤
└──── Σ marketValue(i) ─── adapter.positionValue()
gap = max(0, aggModeledNAV - aggMarketNAV)
gapBps > 1500 → PAUSED
dailyCap = aggMarketNAV × 2%
fill = redeemedToday * 1e18 / dailyCap // 1e18 fixed-point — mandatory
exitNAV = aggMarketNAV + gap × (1 - fill)² // total vault valuation, not per-share