Skip to content

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

dailyCap = aggMarketNAV × dailyCapBps / 10000
         = aggMarketNAV × 2%

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