Skip to content

Writedown & NAV

🚧 In Development

This page reflects v3.0 model. The collateral_type enum is replaced by collateral_description (TEXT) + optional collateral_ratio (NUMERIC). NAV processing: Aset oracle Lambda always processes (v3-15) — nav_data_source dimension removed.

How NAV reflects real-world asset value changes. Covers writedown mechanics, NAV update flow, timelock enforcement, and portfolio display guidelines. For redemption payout calculations, see Redemption.

Writedown vs Wind-Down (different failure modes)

Two distinct loss events — often confused because both involve loss, but they apply to different scenarios and use the same NAV mechanism at settlement (v3-12).

WritedownWind-Down
What's failingUnderlying assets lose valuePartner itself unresponsive 60+ days
TriggerNAV update (DPD spike, defaults)Admin proposeWindDown() + 30-day timelock
Pool lifecycleACTIVE (continues), or IMPAIRED (if escalated)WIND_DOWN (terminal)
Reversibility✅ NAV can recover❌ One-way
FrequencyMultiple times during pool lifeOnce (terminal)
New deposits✅ Allowed (at lower NAV)❌ Blocked
Investor actionNone (passive value change)requestRedemption → funded settlement → claim; The current source has no claimRedemptionFallback. There is no seven-day fallback payout guarantee. The separate epoch settlement and claim-on-behalf paths also depend on actual funding.
NAV calculation(total_deposited − uncoveredLoss) / totalSupplyno reserve term (R8); see §How the Buffer Absorbs Loss and the principal-term noteThe current executeWindDown() does not recalculate navPerToken. It preserves oracle NAV; actual payout requires funding and the redemption process.

The current executeWindDown() does not recalculate navPerToken. It preserves oracle NAV; actual payout requires funding and the redemption process. The current source has no claimRedemptionFallback. There is no seven-day fallback payout guarantee. The separate epoch settlement and claim-on-behalf paths also depend on actual funding.

Why different scenarios need different responses: Writedown handles asset loss while partner cooperates (NAV update + standard redemption). Wind-down handles partner absence (no NAV updates possible → forced NAV based on what's recoverable on-chain).

R10 — the wind-down denominator had to be fixed with the numerator

The reserve-balance formulas in this section are legacy implementation history. They do not apply to the external-reserve implementation. The current executeWindDown() does not recalculate navPerToken. It preserves oracle NAV; actual payout requires funding and the redemption process.

v3-100 (= D-1) set out to fix the wind-down numerator by subtracting redemptionCommitted, the USD already promised to investors whose redemption settled but who have not claimed yet. The intent was right — that cash belongs to named claimants, not to everyone — but the denominator was not moved with it, and totalSupply still counted the escrowed LP backing those same payouts. Their USD was out of the numerator while their tokens stayed in the divisor, so every remaining holder was underpaid by that slice.

Both sides are now fixed, and the numerator fix turned out to be the opposite of what v3-100 shipped:

now       navPerToken = distributable / (totalSupply − settledUnclaimedLp)
          distributable = reserveBalance + totalEpochTopUp

pre-fix   navPerToken = (distributable − redemptionCommitted) / totalSupply

redemptionCommitted is not subtracted any more: _reserveFilledGross already debits the three liquidity buckets at settlement and parks the gross in redemptionCommitted, a disjoint bucket, so subtracting it again removed the same dollars twice. The two errors compounded rather than cancelling — one shrank the numerator, the other inflated the denominator — which is why both had to move in one change.

settledUnclaimedLp is a new on-chain counter: + at settlement (when a fill is reserved into redemptionCommitted) and at claim/burn. It increments at settlement, not at request.

⚠️ R10 excludes settled-unclaimed LP only — never pending LP

This is the part that is easy to over-apply, and over-applying it breaks the epoch model's pro-rata guarantee.

LP in escrowIn redemptionCommitted?In the denominator?Why
Settled (filled), unclaimed✅ yes — USD already reservedexcluded (R10)USD out of the numerator, so the tokens must come out of the denominator. Symmetric.
Unfilled / rolled-over (pending)❌ nostays inTheir USD is in the numerator too, so they participate pro-rata like any holder. Symmetric already.

Removing pending LP would violate Epoch Redemption §5, where rolled-over requesters explicitly keep participating pro-rata.

Also do not confuse this with the yield denominator, which excludes pool-held LP — the same expression as before v3-131 (3), but no longer the same rule: under v3-104 Option A escrowed LP earned nothing, and now it earns while the booking is deferred to the escrow's boundary (previewEscrowAccrual is the deferred part). Three different denominators, three different formulas: see Glossary → 분모.

Shipped — first deployed to dev 2026-08-04 (factory 0xE1E2E974…DA90 → pool implementation 0x27D9948F…f968, commit 0abe168); the settledUnclaimedLp() getter is present on that implementation (selector 915d0aa1). ⚠️ That factory has since been superseded by the 2026-08-06 round (0x09614f0B…Ed0d), which also carries R10 — always create pools from the current factory in apps/contract/sepolia.md, not from an address quoted in prose here. ⚠️ New pools only: pools are Clones with the implementation fixed at creation, so a pool created from the new factory prices on the now line while every earlier pool still prices on pre-fix, where the getter reverts. Concretely, pre-fix returned max(0, 50 − 100) = 0 for a pool holding $50 of unclaimed liquidity — it priced the remaining holder at zero. See v3-109.

🔴 Decided 2026-08-27 (v3-152, v3-155) executeWindDown will stop recomputing NAV (not yet reflected; the contract still recomputes it).

The reason is the reserve leaving the pool: once reserve sits in an external EOA, the pool's own balance is no longer a meaningful number to divide by. The honest figure is the book value, and what controls the actual payout is the arrival of funding, not a recomputed price. So the NAV the oracle last set becomes the liquidation price.

⚠️ Everything above stays true for the pools it was written for. Pools are Clones with the implementation fixed at creation, so a pool already deployed keeps its own implementation's behaviour permanently — settledUnclaimedLp and R10 continue to describe it exactly. The change applies to pools deployed after that round.

Loss Protection Layers

Losses absorb in this order (R8, v3-109) — the reserve is not in this list:

collateral → buffer → Junior → Mezzanine → Senior → NAV
LayerWhat it isParameter / formulaNotes
0 · CollateralOff-chain recovery, before the platform is touchedcollateral_description (text) + collateral_ratio (100 = fully backed, 150 = 150% over)The partner liquidates on the underlying loan; Aset recognizes the loss via NAV only after recovery is factored in. Fields →
1 · Buffer (first-loss, R6)The manager's own equity capitalbufferCap = total_deposited × buffer_rate_bps / 10000buffer_rate_bps = 0 on every pool today, so the term vanishes and the first realized dollar reaches investors. Three columns configure it — below
2 · TrancheGrouped pools only (v3-14)Junior → Mezzanine → Senior NAV waterfallJunior absorbs to its full capital before any reaches Mezzanine. Standalone pools (tranche_group_id NULL) skip this layer — "first-loss = Junior" is what this refers to. Detail →
3 · NAV priceThe investor-visible price(total_deposited − uncoveredLoss) / totalSupply (R9)Floats down from $1.00, capped at $1.00 (no premium). Total loss renders 1e-6, never $0why. New investors always buy at the current fair price

The reserve is not a loss layer (R8). It is carved out of investor deposits (reserve_bps of each one stays in the Pool contract), so it is already inside the investor claim the NAV denominator prices — netting it off the loss as well credited investors twice for their own money. Earlier versions of this page called it "Layer 1: first-loss"; that was the double-count. Reserve = redemption liquidity + the wind-down floor, nothing else.Pool Models · Overview

The buffer is stateless, and it is configuration rather than an answer. uncoveredLoss is recomputed from the cumulative figure on every run, so bufferCap is a constant threshold — there is no buffer_cap or buffer_balance column and none is wanted. The partner's contract terms are still outstanding, but the defaults reproduce today's arithmetic exactly, so an agreed rate is an UPDATE and not a formula rewrite. Why a balance would double-count →

The buffer's three columns (0118)

The rate alone settles nothing. equity_buffer_rule had been promising investors in prose ("Manager absorbs NPL up to N%") without a slot in any formula; R6 makes it computable.

ColumnValuesWhat it decides
buffer_rate_bps0–10000, 0 on every pool todayThe size of the layer. At 0 the term vanishes
buffer_directionFIRST_LOSS (default) / EXCESSWho takes the loss. 5% cap, 8% loss: investors take 3% under FIRST_LOSS, 5% under EXCESS — opposite liabilities from the same number, which is why it could not be assumed
buffer_basisGROSS (default) / NETGROSS = the partner reports total realized loss and Aset subtracts the cap. NET = already net of their absorption, so the cap is forced to 0. Getting this backwards subtracts the buffer twice — the same double-count R8 removed from the reserve

The base is total_deposited (pool size), the structured-credit convention, which keeps the buffer on the same axis as the loss it offsets. Deliberately not a percentage of NPL: per R5.5 delinquency alone never moves NAV, so an NPL-scaled cushion would imply a link that does not exist.

Applies on every standalone pool (0122), not only those with a partner feed — the constraint now narrows to tranche pools only (buffer_not_on_tranche_pools), whose NAV comes from the waterfall engine instead. The prose follows the config: equity_buffer_rule requires a configured rate (0121), so the investor line "Manager first-loss commitment" cannot describe a layer that does not exist.

FX is not an input to NAV (R7)

For pools whose assets are denominated in a local currency (operating_currency ≠ USD), NAV measures asset performance in that currency only. A month where the assets are flat and IDR depreciates 4% leaves NAV unchanged; the currency movement is realized at redemption, when the payout converts at the then-current rate.

Two consequences worth carrying: an FX move never queues a 24h timelock, because NAV did not move and there is nothing to announce. And a $1.00 NAV on a non-USD pool is not a claim about present USD value, so any screen showing it should show the reference USD conversion beside it. pools.fx_rate is for that display conversion and nothing else. → Pool Models → operating_currency

Consequences when NAV < $1.00: new investments stay open (priced at the announced NAV during a decrease timelock — R2·R3); existing yield continues on the nominal deposit amount; redemptions pay tokens × nav_at_request with the same carve-out; decreases wait 24h, increases apply immediately.

NPL vs Cumulative Loss (Loan Lifecycle)

Delinquency data carries two thresholds that mean different things — conflating them is the most common ingestion error. A single loan crosses them in order:

  • npl_threshold_days (fund-reported; default 90) — the loan is now non-performing (NPL) but stays on the book. Still an asset, still being collected; it remains in the DPD buckets and counts toward outstanding principal. A leading, unrealized risk signal.
  • write_off_policy (fund-discretionary; SEA P2P typically ~180 DPD) — the fund gives up and removes the loan from the book. Only now does it leave outstanding / NPL and become realized loss (cumulative_loss), which is embedded in NAV.
DPD:  0 ─────── 30 — 60 — 90 ─────────── 180 ──────►
      current    └ aging buckets ┘  │              │
                                    │◄─── NPL ───►│
                              npl_threshold     write_off
                              (non-performing)   (realized loss)

      ├──────── still on book ────────────────────┤├─ removed from book ─
      └──────── outstanding_principal ────────────┘└─ cumulative loss ───┘
  • NPL balance = exposure in buckets where lower_days ≥ npl_threshold_days that is not yet written off (still on the book). Feeds npl_ratio = NPL balance ÷ total_outstanding_principal — a leading risk-badge signal (04-pool-models), separate from NAV.
  • Cumulative loss (cumulative_loss) = principal written off — realized loss that feeds the writeoffs term of the NAV formula. It is not a provision/allowance, and not the NPL figure. Display-only on the badge.
  • The two never overlap at a point in time: a dollar is either on the book (possibly NPL) or written off.

⚠️ TODO(N-1) — the code does the opposite of this paragraph, and the fix is pending. apps/infra/src/infra/report/sources/joob/observe.ts feeds the partner's totalNpl (delinquent balance, still on the book) into the CUMULATIVE_LOSS metric, so NAV is currently priced off money that is late rather than money that is lost. The agreed direction is to request a recovery/write-off field from the partner API and then align this page with the code — the exact wording depends on what that request returns, so it is deliberately left unwritten here.

Relationship to the OJK schedule · Joob's confirmed policy (2026-07-24)

If a fund writes off at the same DPD it declares NPL, the two collapse (on-book NPL ≈ 0). The OJK graduated schedule is one such mapping — it recognizes partial loss (5 / 15 / 50 %) while the loan is still NPL on-book, reaching 100 % (full write-off) at 181+. Funds outside OJK — most SEA P2P — set their own write_off_policy; Aset does not impose a provisioning ratio, it ingests what the fund reports. See v3-72.

Joob's actual policy — NPL at DPD 90, write-off at ~180 DPD, cash-received fund value. The 2026-07-24 Joob call pinned the long-open items for the FJO fund:

  • NPL at DPD 90; write-off at ~6 months (≈180 DPD). A loan is classified non-performing at 90 DPD but stays on the book — it remains in the DPD-90 bucket and counts toward outstanding principal, so it feeds npl_ratio. Actual write-off (removal from the book into realized cumulative_loss; Joob's separate "written-off / in recovery" line) happens at roughly 6 months (~180 DPD). So npl_threshold_days = 90 and write_off_policy ≈ 180 do not collapse: there is a real 90–180 DPD on-book NPL window, and the leading NPL risk-badge signal fires normally (e.g. the 2026-07-24 snapshot ≈ 10.6% NPL → HIGH).
  • current_fund_value is on a cash-received basis, and Aset mirrors it as-is — it counts only interest actually received, not approved-but-uncollected (accrued / 미수) interest. Since the approved→received lag is only ~1–2 days (immaterial vs NAV), Aset ingests the cash fund_value directly rather than adding a separate accrued_income term; that request is dropped (column dormant). Joob is moving fund value (like DPD) to an EOD daily 09:00 refresh.

See Joob Pool Configuration → Fund value basis and v3-90.

Automated DPD Writedown — deferred, not built

🚧 Not implemented, and nothing below is wired. Blocked on Joob per-loan data: the guest API exposes only aggregate DPD buckets (30/60/90 on the fund route; 30/60 plus /risk/nonperforming on the master route, where bucket 90 was retired — see Guest API surfaces), no per-loan principal, so the DPD→OJK→updateNAV scheduler is explicitly deferred in api-stack.ts. Today report.scheduler.fetch only records buckets as observations for display, the OJK schedule exists only as a SQL comment, loan_writeoffs has no writer, and there is no JOOB_DPD_AUTO NAV producer.

⚠️ What is missing is the DPD→OJK per-loan pipeline, not automated NAV derivation. The pricing sweep already runs — runPricingSweep (apps/infra/src/infra/nav/pricing/index.ts), invoked from report.scheduler.derive — and derives NAV from the partner's reported cumulative loss. It opens an OPEN proposal — a human still approves, overrides or dismisses each one, so "a person is involved" was never the thing in dispute. What is automated is deriving the number. Typing a NAV by hand through POST /nav-changes is the correction / restatement path the formula cannot express, not the only path. → Two paths, one formula

The deferred design — target once per-loan data exists

For pools with external_provider = JOOB (and future partners exposing per-loan data), the planned design is a daily joob-dpd-sync Lambda that would:

  1. Pull per-loan DPD data via partner API (e.g., Joob eNote endpoint)
  2. Apply a write-off schedule per loan
  3. Aggregate loan-level writeoffs into pool-level NAV adjustment
  4. Call Pool.updateNAV(newNav, 0) — subject to standard 24h timelock (reserveConsumed = 0 per R8)

Initial Write-Off Schedule — OJK Standard (POJK No.40/2019)

Indonesian regulatory standard. Joob operates under OJK so their internal provisioning likely matches.

DPD bucketOJK classWrite-off rate
1–90DPK (Special Mention)5%
91–120Kurang Lancar (Substandard)15%
121–180Diragukan (Doubtful)50%
181+Macet (Loss)100%

Joob confirmed (2026-07-24): NPL at 90, write-off at ~180 DPD

Joob classifies a loan NPL at DPD 90 (stays on book) and writes it off at ~6 months (≈180 DPD) — the two are separate, so an OJK-style 90–180 aging window applies before realized loss. Whether Joob provisions partial loss on the graduated 5/15/50/100 tiers during that window (vs holding full book value until write-off at ~180) is not confirmed — the schedule above is retained as the deferred design template, and since the automated DPD→NAV pipeline is deferred (NAV manual) it isn't wired either way. Other partners (LINE BK, future) will define their own schedules — likely promoted to a per-pool writeoff_schedule dimension (v3-13.1, deferred). See v3-90.

Audit Trail

Each automated writedown would log:

  • loan_writeoffs row per affected loan (loan_id, dpd_bucket, rate_applied, amount, snapshot_date)
  • nav_history row at pool level (source = JOOB_DPD_AUTO, reason includes aggregated count)
  • Admin can review pending NAV update during 24h timelock; manual override possible

All pools use a single processing path:

Partner data source (API or admin input)
  ↓ raw inputs: asset values, DPD, repayments, NPL, collateral status
Aset Oracle Lambda
  ↓ applies: write-off schedule (v3-13 OJK), tranche waterfall (v3-14), Aset formula
PlatformPool.updateNAV(new_nav)
  ↓ 24h timelock for decreases
nav_per_token updated

Aset always processes NAV (v3-15) — even when partners offer their own NAV number, Aset still ingests, validates, and adjudicates. There is no "pure mirroring" mode.

PartnerRaw data sourceAset processing
JoobeNote API (per-loan DPD, RNI, NPL)OJK write-off schedule → aggregate NAV
LINE BK (planned)LFC risk dashboard (their NAV suggestion)Sanity check + tranche waterfall → final NAV
Aset direct poolsInternal portfolio dataAset's own valuation formula

How NAV Is Calculated

One canonical formula. It is absolute — recomputed from the cumulative loss on every run, never chained off the previous NAV — so repeated write-downs cannot drift and bufferCap acts as a constant threshold. Symbol names match nav-formula.ts one-for-one so anything here can be grepped there; total_deposited is the one exception and is a label, not a column (why).

🔴 Decided 2026-08-27 (v3-153) (not yet reflected). For interest accrual — a different engine from the NAV formula below — the principal is paid-in capital (money in minus money returned), not the LP token count the deployed engine uses, and accrual does not follow NAV: interest is charged on principal, not on the written-down value (why, and the IFRS 9 §5.4.1(b) basis).

lossRatio      = cumulative_loss ÷ total_subscribed   ← both from the SAME partner report (R7)
cumulativeLoss = lossRatio × total_deposited          ← now in the pool's currency

bufferCap      = total_deposited × buffer_rate_bps / 10000   ← forced to 0 when basis = NET
uncoveredLoss  = FIRST_LOSS ? max(0, cumulativeLoss − bufferCap)
                            : min(max(0, cumulativeLoss), bufferCap)
                                                      ← buffer, NOT reserve (R8)

# grouped pools only: Junior → Mezzanine → Senior consume uncoveredLoss next (v3-14)

rawNav         = (total_deposited − uncoveredLoss) / totalSupply   ← on-chain LP supply (R9)
suggestedNav   = clamp(rawNav, 1e-6, 1.0)
escalate       = rawNav <= 0        ← routes to proposeImpairment, never auto-applied

pool.updateNAV(suggestedNav, 0)     ← reserveConsumed is always 0 under R8

Under FIRST_LOSS losses up to bufferCap don't reduce NAV; under EXCESS the reverse — investors take them and the manager takes only the overflow. After the call navPerToken = suggestedNav, or it queues for the 24h timelock if this is a decrease. reserveBalance is untouched by a write-down.

total_subscribed is the fund's own cumulative committed principal as the partner reports it (external_pool_data_snapshots.total_subscribed, 0054) — not our pool's deposits. That is the point of the first line: both inputs come from the same report in the same currency, so the currency cancels and lossRatio is dimensionless. A snapshot with a missing or non-positive total_subscribed yields no ratio and the sweep skips the pool rather than guessing.

Automated (A4)Manual
Entryreport.scheduler.derive (runs runPricingSweep) → admin approve / override / dismissPOST /nav-changes
Inputcumulative_loss from the partner reportcumulative_loss (preferred) or new_nav typed directly — never both
ScopeStandalone pools (tranche NAV stays with POST /tranche-writedown)Same
FormulacomputeNavFromPoolLossthe same computeNavFromPoolLoss

One composition is deliberate. While the sweep was the only caller, the buffer applied to 1 of 13 standalone pools and nothing about the other 12 said so. The new_nav form exists for corrections and restatements the formula cannot express; the derived form records nav_history.loss_amount / loss_as_of, so a NAV drop can name its own cause.

Three guards on the loss form, each a failure that already happened: cumulative_loss > total_deposited is refused (a cumulative loss above total deposits is a data error or a total loss, and this is also what catches an R7 currency mix-up typed in by hand); a computed wipeout is refused and routed to proposeImpairment (D3); and the admin preview is the server's own dry_run — running the formula and the on-chain simulate — rather than a second copy of the formula in the browser.

Implementation status: shipped, denominator included, both paths. The reserve term is gone (R8), the buffer is real configuration (R6), and the denominator is the on-chain LP supply (R9) — usableTotalSupply returns null and callers skip the pool rather than falling back to net_principal, which is exactly the pre-R9 bug. → 14-decisions A4 · v3-109

updateNAV's second argument survives, but must be 0 (R8)

The deployed signature is still updateNAV(uint256 newNav, uint256 reserveConsumed) — v3-16 made reserve consumption atomic with the NAV write, and R8 does not change the ABI. What changed is the value.

It is a chain constraint, not a caller convention. Every backend path sends 0 (apps/infra/src/infra/nav/apply-proposal.ts:120 and — for the propose path — apps/infra/src/infra/nav/apply-plane.ts:18,25, both via a NO_RESERVE_CONSUMED constant; lambda/nav-changes.post.propose.ts is a 29-line route shim, the logic is apps/infra/src/usecases/nav/propose-change.ts, which reaches the chain through ctx.navApply; the old min(liveReserveBalance, newLossDelta) expression is gone), and NavLib.updateNAV reverts InvalidAmount on any non-zero value (NavLib.sol:109-111), so the old model cannot be reintroduced by passing a plausible number. The guard sits ahead of the increase/decrease branch — the decrease path was the half free to pass a debit. → 08a-contract-reference

Reserve is depleted by exactly two things, neither of which is a loss:

  • Redemption payouts — when an investor redeems, USDC physically leaves and reserveBalance drops by the payout. If insufficient → PENDING_RESERVE triggers partner top-up via fundRedemption().
  • Wind-down distributionexecuteWindDown prices the terminal pro-rata exit off the reserve plus recalled funds (v3-100).

Early-redemption penalties (PRINCIPAL_BASED / FLAT_FEE / YIELD_BASED, instant + epoch) are transferred to the pool's fund_wallet (v3-85), not reserveBalance — so they leave the pool and affect neither the reserve nor NAV. ✅ Shipped (v3-84/v3-85) — the deployed RedemptionLib routes early-redemption penalties to fund_wallet; see 14-decisions v3-85.

See v3-16 for the reserve state transition matrix (its loss-absorption framing is superseded by R8; the state transitions themselves still hold).

#StepWhat happens
1Aset processes 🟡 OracleThe oracle ingests partner raw data (or admin input), runs the canonical formula — buffer absorption, tranche waterfall where applicable — and calls PlatformPool.updateNAV(newNav, 0). A nav_history row is created PENDING
224h timelock — decreases only 🟢 Systemeffective_at = now() + 24h, investors + partner notified, status PENDING. Increases apply immediately. An FX-driven move is not timelocked because it is not a NAV move at all (R7)
3Takes effect 🟢 Systempools.nav_per_token updated, nav_historyAPPLIED. New deposits price at the new NAV; requests made before the announcement keep their earlier nav_at_request, requests made inside the window were already priced here (R2·R3 below). Investment stays open — new investors pay the updated fair price

If the data source is offline 🔴 Admin NAV stays at its last known value. For serious cases an admin can pause() the pool (is_paused = true), which blocks new deposits while keeping redemptions open.

⚠️ R2·R3 — the announced NAV prices everything inside the timelock window

A pending decrease is public for 24 hours before it applies. Pricing new activity at the old, higher NAV during that window is a free option: a new investor could buy at $1.00 knowing $0.98 lands tomorrow, and an exiting investor could lock $1.00 and leave the loss with everyone who stayed. Epoch pools are immune (they price at settlement), so this is an instant-pool problem specifically.

Decided (R2·R3, v3-109; extended to reinvest by v3-111): while a decrease is pending, all three capital-moving paths price at the announced new NAV, not pools.nav_per_token. One rule, one price, no path-dependent exceptions.

PathPrices at
DepositeffectiveNav — ✅ deployed 2026-08-04
Redemption requesteffectiveNav — ✅ deployed 2026-08-04
ReinvesteffectiveNav — ✅ deployed 2026-08-06 (v3-111)
TimingPriced at
Submitted before the proposal was announcedold NAV (unchanged — a snapshot already taken stays taken)
Submitted during the 24h windowthe announced new NAV
Submitted after it appliesnew NAV (it is now pools.nav_per_token)

This replaces the earlier reading of this page, which said investments "stay open" at the current price and pending requests keep their snapshot — true for the second and third rows, wrong for the middle one.

All three paths are deployed — but in two rounds, and a pool follows whichever implementation it was born with. Deposit and redemption read PoolCommonLib.effectiveNav from the 2026-08-04 implementation (PlatformPool.sol:756 deposit, RedemptionLib.sol:279 + :327 redemption request, commit 0abe168). Reinvest joined them in the 2026-08-06 implementation (YieldLib.sol:299, v3-111, covered by test_ReinvestPricesAtAnnouncedNavDuringDecrease). See v3-109.

⚠️ Which pools price correctly depends on when they were created

Pools are Clones with the implementation fixed at creation and non-upgradeable, so this is not a rollout that catches up — a pool never changes behaviour. There is no migration; the only way a pool prices on a newer rule is to be created under a newer factory.

Pool created underDepositRedemption requestReinvest
the 2026-08-06 factory (0x09614f0B…Ed0d) — current✅ announced NAV✅ announced NAV✅ announced NAV
the 2026-08-04 factory (0xE1E2E974…DA90)✅ announced NAV✅ announced NAV⚠️ stale (higher) NAV → mints fewer LP than a same-block deposit
anything earlier (incl. bootstrap pool #0)⚠️ stale⚠️ stale⚠️ stale

Deployment addresses per round: apps/contract/sepolia.md. ⚠️ Backend FACTORY_ADDRESS_84532 has to point at the current factory, or new pools are created from an older implementation again.

On-chain NavLib._checkNavBound enforces a circuit breaker + bidirectional deviation cap on every updateNAV (both the increase and the decrease path), and RedemptionLib.executeEpoch additionally enforces NAV staleness at epoch settlement. The backend pre-flights a NAV proposal with simulateUpdateNav (a viem simulateContract / eth_call dry-run against live on-chain state) and decodes the custom error (CircuitBreakerActive / NavDeviationExceeded / InsufficientReserve / …) into a clean 4xx — so a tripped breaker or an over-cap move never wastes gas or drifts the DB. The deviation cap and staleness threshold have no on-chain getter (setters only), so the simulate is the source of truth — they are deliberately not mirrored to the DB.

Investor UI: none (by design). The breaker / deviation cap / staleness are operational guard rails — resetting the breaker or setting the cap are PAUSER/ADMIN multisig actions, and there is no investor-actionable state to expose. The investor app therefore surfaces no circuit-breaker or NAV-staleness indicator; staleness at settlement is handled by the epoch scheduler's epoch_circuit_breaker / epoch_execute_failed alerts (ops), not an investor display. Investors see NAV changes through the writedown banner + NAV history (below), which is the meaningful, actionable signal.

Implementation notes & rationale

Why the formula above has the shape it does. None of this is needed to use the page — it is here so a future change cannot re-introduce a defect that has already cost something once.

R7 — the currency crossing is a ratio, and a healthy pool was one approval from zero

The partner reports in its own currency; the pool's book is USD. The deployed path subtracted one from the other directly: 1,022,311,789 EFIDR minus 573,206 USD returns −1782, which clamped to the floor and set escalate_flag. The only NAV proposal dev ever produced was a total-loss write-down on a pool whose loans were all performing.

The crossing is a ratio and deliberately not an exchange rate. Both inputs come from the same report, so the currency cancels and the result is dimensionless — which means no FX move can reach NAV, which is what R7 requires. Converting with pools.fx_rate would produce an equally believable number and put FX straight back inside the price. fx_rate is for display only.

R6 — why a depleting bufferBalance double-counts

The earlier shape of this formula carried a running balance: bufferBalance = bufferCap − bufferAbsorbed, then uncovered = loss − bufferBalance. That subtracts the absorbed amount twice — at loss = cap = 100 it reports 100 uncovered when the answer is 0. The NAV computation is absolute, recomputing from the cumulative figure every time, so the buffer needs no state, no buffer_cap column and no buffer_balance column. A regression test pins the wrong version out. bufferAbsorbed = min(loss, cap) survives as a display value only, for showing an operator how much of the layer is used.

Why it shipped as configuration rather than as an answer. Waiting on the partner's contract terms is a wait with no end date on a formula that runs every 12 hours. What the wait protects against is guessing, and a column does not guess. FIRST_LOSS is the default only because its zero value preserves current behaviour — EXCESS at rate 0 would mean investors absorb nothing.

It briefly applied to one pool only: while the suggestion sweep was its sole reader, a buffer on any of the other 12 would have been stored and never applied, and 0119 banned it for that reason. That was the right answer to "nothing reads this" and the wrong answer once something could — POST /nav-changes now takes a cumulative loss and runs the same formula, so 0122 narrowed the constraint to tranche pools only.

R9 — why totalSupply and not the principal (shipped 2026-08-04)

NAV must move on loss and on nothing else. Dividing by the principal broke that in both directions, and the redemption case is the one that cost money:

Event on a $1,000 pool written down to 0.90÷ principal (old)÷ LP supply (R9)
redeem 500 tokens for $4500.81820.9000
then deposit $9000.93100.9000
then write off $50 more0.89660.8667

The exiting investor's write-down was charged a second time to whoever stayed, then partly refunded by the next deposit. Neither move had anything to do with the assets.

The identity behind the fix: with p = (V − L)/S, a redemption of T tokens takes V to V − T·p and S to S − T, so the price recomputes as p(S − T)/(S − T) = p. A deposit of A mints A/p tokens and cancels the same way.

🔴 This depends on the numerator being NET of redemptions. complete_redemption_atomic does tvl = tvl − payout, which is what makes the cancellation work. The v3-109 decision text calls this term total_deposited and describes it as a figure that "only grows" — that is not what the column does, and a well-meaning change to make it cumulative would keep the exited investor's principal in the numerator while their tokens left the denominator, inflating NAV on every exit.

A missing supply is not a zero one. pools.lp_total_supply is NULL until the indexer mirrors the pool, and usableTotalSupply() refuses NULL, 0 and negatives alike — callers skip the pool rather than substituting the principal, because that substitution is precisely the old denominator and nothing on screen would reveal it. Dev currently has four pools in that state, including one holding $7.1m against a supply of 0 (confirmed on-chain, not a mirror fault).

⚠️ R9 also turns ledger drift into a visible price. The old form returned exactly 1.0 at zero loss for every pool no matter how inconsistent its data, so it could never surface anything. One dev pool carries $2.30 of principal against 19 tokens and now prices at $0.121 — that is the honest book value of those rows, and the disagreement was always there. pool_position_stats.lp_supply is a sum of DB positions and must not be substituted for the on-chain mirror.

clamp(…, 1e-6, 1.0) is not a floor mechanism

Read the lower bound as a representability limit, not a policy. There are four independent guards and no single "floor":

GuardWhereWhat it does
newNav == 0 → InvalidNavcontract updateNAVrejects zero outright; also clamps anything above $1.00
CHECK (nav_per_token > 0)DBrejects zero at persistence
NAV_FLOOR = 0.000001tranche.post.writedown.ts onlyfloors a wiped tranche's computed NAV. nav-changes.post.propose has no such constant
settleNav == 0 → InvalidNavepoch settlementcorrupt-oracle bug-guard (v3-100) — unrelated to write-downs

Consequence to carry into any UI or copy: "NAV 0" is not representable anywhere. A total loss is 1e-6, and the thing that communicates "effectively zero" is the lifecycle transition to IMPAIRED, which runs on its own 7-day on-chain timelock (v3-106) — not the number.

⚠️ Latent conflict: with navDeviationCapBps > 0, a wipeout (1.0 → 1e-6) exceeds any cap, so _checkNavBound reverts and the write-down cannot be applied until the cap is lifted. Not live today — nothing calls setNavDeviationCap and pools.nav_deviation_cap_bps is unused — but enabling the cap requires an explicit lift → write down → restore step first.

Governance around a NAV proposal (D1 · D2 · D3 / T2)

Three decisions in v3-109 tighten who can move NAV and what must follow a wipeout.

What it settlesWhy
D1 — the manual path is governedPOST /nav-changes and POST /tranche-writedown are ADMIN / SUPER_ADMIN; OPERATOR removedA4 approve/override was already ADMIN-only, so leaving the hand-typed route open to OPERATOR made the weaker control the effective one — anyone skipping the suggestion layer just used the other endpoint
D2 — a proposal can be dismissed, and expires on its ownExplicit dismiss with a recorded reason, plus a 48-hour TTL so an untouched proposal expiresA suggestion an admin disagreed with previously had nowhere to go: approve, override, or leave it OPEN forever blocking the next sweep (the material gate refuses a second open proposal). A dismissal is an audited act, not a delete — the reason is what a later reviewer needs
D3 / T2 — a total loss must escalate to on-chain IMPAIREDrawNav ≤ 0 transitions the pool via proposeImpairment7-day timelockexecuteImpairment (v3-106)escalate_flag alone was a warning with no forced consequence — a wiped pool could sit at ACTIVE with a 1e-6 NAV still taking deposits. T2: the timelock replaces the two-person approval once floated here. Two signatures are simultaneous and private; seven public days let investors, the partner and our reviewers object, and it composes with the exit right (deposits blocked, redemptions open)

Worked example

When a loss event occurs (e.g. a borrower defaults), NAV decreases to reflect it. Step-by-step, as the Aset oracle Lambda runs it:

📖 Step-by-Step Example

Scenario: Joob EWA Fund pool has $1,000,000 in total deposits. A borrower defaults on a $120,000 loan.

Step 1 — Check the manager's equity buffer first

The absorbing figure is bufferCap — the manager's first-loss capital (R6), not the pool reserve.

  • Why not the reserve. It is investor money already inside the claim NAV prices, so netting it against a loss would credit investors twice (R8).
  • Worked example. Say the confirmed buffer is 10% of total_deposited under FIRST_LOSS, so bufferCap = $100,000. It covers $100,000 of the $120,000 cumulative loss → remaining uncovered loss = $20,000.
  • The cap is a threshold, not a balance that draws down. uncoveredLoss = max(0, cumulativeLoss − bufferCap) is recomputed from the cumulative figure every time, so a pool whose reported loss has since grown shows the same cap and a larger uncovered figure.
  • ⚠️ At launch this step absorbs nothing. buffer_rate_bps is 0 on every pool, so bufferCap = 0 and the first realized dollar of loss reaches investors, with collateral the only layer in front.

Step 2 — Check Junior tranche (if applicable) For pools that are part of a tranche group (v3-14), the Junior pool absorbs the remaining uncovered loss next via NAV waterfall (its NAV drops first). For standalone pools (tranche_group_id NULL), skip this step. Only a fully exhausted tranche escalates to IMPAIRED; one that absorbed a partial loss stays ACTIVE with NAV < 1.0 (v3-106). The group path is POST /tranche-writedownADMIN / SUPER_ADMIN (v3-106; it accepts OPERATOR until that lands), applied per pool as a direct updateNAV write, not through A4 approve / override. Detail: 04-pool-models → Loss Waterfall.

Step 3 — Calculate the new NAV Remaining uncovered loss (after the buffer + Junior, if any) reduces nav_per_token:

New NAV = Current NAV × (1 − Uncovered Loss ÷ Total Deposits)

New NAV = $1.00 × (1 − $20,000 ÷ $1,000,000) = $1.00 × 0.98 = $0.98

This multiplicative chain is a mental model, not the implementation. It is kept because it is how a person reasons about "a 2% write-down" by hand, and its ÷ Total Deposits is a share-of-the-pool intuition — not the live denominator. The canonical formula computes NAV absolutely: (total_deposited − uncoveredLoss) / totalSupply = ($1,000,000 − $20,000) / 1,000,000 tokens = $0.98. The two agree here only because NAV is exactly $1.00 and nobody has redeemed. Once NAV has moved or LP has been burned they diverge, and the absolute form with the totalSupply denominator (R9) is canonical.

By hand on an already-written-down pool, use the current NAV in the multiplication — write-downs are cumulative. Step 1 subtracts the buffer, never the reserve (R8).

Step 4 — Apply the 24-hour timelock NAV decreases require a 24-hour waiting period before taking effect. NAV increases apply immediately.

  • ✓ Decrease is 2% → submitted as a pending NAV update
  • ✓ 24-hour timelock — stakeholders can review or prepare
  • ✓ NAV capped at $1.00 max

Step 5 — Existing investor impact An investor who deposited $10,000 at NAV $1.00 has 10,000 tokens. New token value = 10,000 × $0.98 = $9,800. They've lost $200 of token value (2%), but their yield still accrues on the full $10,000.

Step 6 — New investor gets fair entry A new investor depositing $10,000 at NAV $0.98 gets tokens_minted = $10,000 / $0.98 = 10,204 tokens → proportionally more tokens than original investors. Fair entry.

Per v3-15, Aset always runs the formula — partners provide raw inputs (asset values, DPD, NPL), Aset adjudicates. There is no "partner-reported NAV" path that skips Aset processing.

Portfolio NAV Display

When a pool's NAV is below $1.00, the portfolio page shows the writedown transparently — but without alarming design patterns. Investors are sophisticated; hiding information erodes trust faster than showing it.

✓ DO

  • Show: "Invested: $1,000 → Current Value: $880 (NAV: $0.88)"
  • Use neutral colors (grey/blue info card)
  • Link to NAV history timeline for that pool
  • Show the NAV governance bound that actually exists: the $1.00 cap (no premium pricing, so a new investor never overpays). Do not write "$0 floor" — there is no floor, and 0 is not even representable: a total loss renders as 1e-6, and the thing that communicates "effectively zero" is the lifecycle move to IMPAIRED, not the number (details)
  • Include buffer coverage status where a rate is confirmed — that is the layer standing between a loss and NAV (R6). Do not present reserve as loss coverage; it is redemption liquidity (R8)
  • Attribute the drop to its cause — a NAV decrease should link to the write-off that produced it, not appear as an unexplained number

✗ DON'T

  • Red warning banners or alarm iconography
  • Words like "loss", "default", "danger" without context
  • Hide the writedown behind a click/expand
  • Show NAV change without governance/coverage info

→ See Redemption for payout calculations and NAV scenario examples.