> ## Documentation Index
> Fetch the complete documentation index at: https://ormaprotocol.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Orma Method Reference: Formulas and Oracle Encoding

> Complete reference for every number Orma computes: NAV formulas, cover liquidation, ordering fairness, ordinal notching, and oracle encoding.

This page is the complete method reference — every formula Orma uses, the ledger fields it reads, and the source file that implements it. Use it to verify results or build your own implementation. Nothing here requires privileged access: every input is a public ledger field, and every figure the API serves names the ledger state it was computed from so you can recompute it yourself.

## Symbols

| Symbol           | Meaning                              | Ledger field                        |
| ---------------- | ------------------------------------ | ----------------------------------- |
| $A$              | Total assets held by the vault       | `Vault.AssetsTotal`                 |
| $A_v$            | Assets not currently lent out        | `Vault.AssetsAvailable`             |
| $L$              | Loss recognised but not yet deducted | `Vault.LossUnrealized`              |
| $S$              | Units outstanding                    | `MPTokenIssuance.OutstandingAmount` |
| $D$              | Broker's total drawn debt            | `LoanBroker.DebtTotal`              |
| $C$              | First-loss capital posted            | `LoanBroker.CoverAvailable`         |
| $c_{\min}$       | Minimum cover rate                   | `LoanBroker.CoverRateMinimum`       |
| $c_{\text{liq}}$ | Liquidation cover rate               | `LoanBroker.CoverRateLiquidation`   |
| $p_i$            | Principal of exposure $i$            | `Loan.PrincipalOutstanding`         |

### Units

All monetary fields are integer drops (1 XRP = $10^6$ drops), carried as decimal strings of up to 19 significant digits. All arithmetic uses `decimal.js` at precision 40. `Number()` and `parseFloat` are never applied to a monetary value: 19 significant digits do not survive an IEEE-754 double, and `BigInt("1e17")` throws on the scientific notation rippled sometimes emits. The helper `num()` in `src/num.mjs` is the single place where "field absent" is coalesced to zero, because rippled omits any field equal to its type default.

### Rate scale

$c_{\min}$ and $c_{\text{liq}}$ are expressed in units of $10^{-5}$. A stated value of `10000` is therefore $0.10$ — not $10000$ and not $100$.

```js theme={null}
// src/num.mjs
export const RATE_SCALE = 100000
export const rateToDec = (r) => num(r).div(RATE_SCALE)
```

***

## 1. Net Asset Value, Two Ways

Orma computes and reports NAV two ways on every call — not to be thorough, but because the two numbers differ and one of them is silently wrong for any vault carrying an unrealised loss.

The naive reading, which is what a metadata-diffing indexer computes:

$\text{NAV}_{\text{naive}} = \frac{A}{S}$

The correct reading, net of the loss the manager has already recognised:

$\text{NAV}_{\text{held}} = \frac{A - L}{S}$

Both are defined as zero when $S = 0$. Divergence is reported in basis points:

$\delta = \operatorname{round}\!\left(10^{4} \cdot \frac{\text{NAV}_{\text{naive}} - \text{NAV}_{\text{held}}}{\text{NAV}_{\text{naive}}}\right)$

<Note>
  $\delta$ is computed from the full-precision `Decimal` values, not from the rounded six-decimal strings. The integer conversion is a **round**, not a truncation: `bps()` in `src/num.mjs` calls `toFixed(0)`, and `Decimal.prototype.toFixed` rounds half-up. On Calder Structured Credit III the exact value is $1960.78\ldots$ bp, so the API reports **1961**. A floor would report 1960. If you reimplement this and get 1960, that is the reason.
</Note>

The whole computation is four lines of `Reader.nav()` in `src/poll.mjs`:

```js theme={null}
static nav(vault) {
  const { assetsTotal, lossUnrealized, sharesOutstanding: s } = vault
  const naive = s.isZero() ? new Decimal(0) : assetsTotal.div(s)
  const correct = s.isZero() ? new Decimal(0) : assetsTotal.minus(lossUnrealized).div(s)
  return {
    navNaive: naive.toFixed(6),
    navCorrect: correct.toFixed(6),
    navDivergenceBps: bps(naive, correct),
  }
}
```

**Measured on Devnet.** Calder Structured Credit III, $A = 51{,}000{,}000$ drops, $L = 10{,}000{,}000$, $S = 51{,}000{,}000$: $\text{NAV}_{\text{naive}} = 1.000000$, $\text{NAV}_{\text{held}} = 0.803922$, $\delta = 1961$ bp.

### Why the naive figure is not merely lazy

rippled omits from `PreviousFields` any field whose previous value was the type default. On the first impairment of a healthy vault, $L$ moves from $0$, so `PreviousFields` is emitted **empty**. Under cash-basis accounting (`LEVersion = 1`) an impairment changes nothing else on the Vault object. An indexer that diffs transaction metadata therefore observes no change at all on the vault it is watching.

Captured from transaction `075FE6D2E0F29919AF477A2A8F581A680805A006138BB967D8434611E49229C3` (`tesSUCCESS`, Meridian Trade Finance I):

```json theme={null}
"vaultNode": {
  "previousFields": {},
  "finalFields": {
    "AssetsTotal": "51000000",
    "AssetsAvailable": "41000000",
    "LossUnrealized": "10000000"
  }
}
```

<Warning>
  This is why Orma polls full object state every 4 seconds and never diffs `PreviousFields`. A diffing reader is not slightly late here — it is **silent**, until some later transaction happens to touch the same field for another reason. Reporting both NAV readings, with the gap named, lets a consumer see what a naive computation would have told them and decide for themselves.
</Warning>

***

## 2. First-Loss Cover

### Cover required and cover shortfall

$\text{cover\_required} = \lceil D \cdot c_{\min} \rceil$

$\text{cover\_shortfall} = \max\!\left(0,\; \text{cover\_required} - C\right)$

### Max liquidatable now

The amount of first-loss capital that can actually be liquidated in a single default event:

$\text{max\_liquidatable} = \lceil D \cdot c_{\min} \cdot c_{\text{liq}} \rceil$

<Warning>
  Both rates are $10^{-5}$ units, so **both** must be divided by $10^5$. At $c_{\min} = c_{\text{liq}} = 10000$ the coefficient is $0.10 \times 0.10 = 0.01$. Dividing only once makes the answer 100 times too large.
</Warning>

### Stranded cover fraction

Cover that has been posted but cannot currently be liquidated — it is frozen above the liquidation ceiling:

$\text{stranded\_cover\_fraction} = \frac{\max\!\left(0,\; C - \text{max\_liquidatable}\right)}{C}$

Defined as $0$ when $C = 0$.

The derivation in `src/score.mjs`:

```js theme={null}
// src/score.mjs  (inside deriveScoreInputs)
const cmin = D(o.broker.coverRateMinimum).div(100000)
const cliq = D(o.broker.coverRateLiquidation).div(100000)
// The DOUBLE PRODUCT. Both rates are 1e-5 units so BOTH are divided by 1e5.
const maxLiquidatableNow = debtTotal.times(cmin).times(cliq).ceil()
const coverRequired = debtTotal.times(cmin).ceil()
const coverShortfall = Decimal.max(0, coverRequired.minus(coverAvailable))
const strandedCoverFraction = coverAvailable.isZero()
  ? D(0)
  : Decimal.max(0, coverAvailable.minus(maxLiquidatableNow)).div(coverAvailable)
```

***

## 3. Cover Consumed on a Default

When a broker calls `LoanManage` with the DEFAULT flag, the ledger liquidates:

$T(p) = \min\!\Big(\big\lceil D \cdot c_{\min} \cdot c_{\text{liq}} \big\rceil,\; p,\; C\Big)$

Three properties of this formula, each of which matters:

1. **The double product.** Both rates are $10^{-5}$ units, so both are divided by $10^5$ (see above).
2. **The result is ceilinged**, not floored and not rounded.
3. **The base is the broker's total book $D$**, not the principal $p$ of the exposure that defaulted. The same loan defaulting inside a larger book liquidates more cover. $D$ is decremented as each default lands, so each successive default consumes less.

Implemented in `coverForOrder()` in `src/history.mjs`, which mirrors the ledger step by step:

```js theme={null}
// src/history.mjs
export function coverForOrder(principals, startingDebt, cmin, cliq, coverAvailable) {
  const c = num(cmin).div(100000).times(num(cliq).div(100000))
  let debt = num(startingDebt)
  let cover = num(coverAvailable)
  let total = new Decimal(0)
  for (const p of principals) {
    const want = debt.times(c).ceil()
    const paid = Decimal.min(want, num(p), cover)
    total = total.plus(paid)
    cover = cover.minus(paid)
    debt = Decimal.max(0, debt.minus(num(p)))
  }
  return total
}
```

Verified against `LoanManage.cpp:146–169`, with $D$ decremented at line 248.

***

## 4. The Ordering Result

Because $D$ is decremented as each default lands, each default shrinks the base for the next one. The total cover paid across a fixed set of losses therefore depends on the **order** they are declared in.

Write $c = c_{\min} \cdot c_{\text{liq}}$ and let $D_0$ be the book at the moment of the first default. Declaring $k$ defaults in the order $\sigma$ consumes, while cover remains available and each $p_i$ is large enough that the $\min$ never binds:

$T(\sigma, k) = c \left[ k D_0 - \sum_{i=1}^{k} (k - i)\, p_{\sigma(i)} \right]$

Only the sum depends on $\sigma$. The coefficient $(k - i)$ is decreasing in $i$, so by the **rearrangement inequality** the sum is maximised, and therefore $T$ minimised, when $p_{\sigma(i)}$ is decreasing. Hence:

> Total first-loss capital consumed is **minimised by declaring the largest exposure first**, and **maximised by declaring the smallest first**.

The party who chooses $\sigma$ is the `LoanBroker` owner, who is necessarily the vault owner (`LoanBrokerSet.cpp:109`), and is the party whose capital $C$ is consumed. The difference lands on the unit holders.

<Note>
  The closed form drops the ceiling and the $\min$ clamps, which is why it is stated with a proviso. Use the closed form to understand the result; use the simulation in `coverForOrder()` to compute it.
</Note>

### Measured on Devnet

Kestrel Bridge Financing II: $p = \{30\ \text{XRP},\ 10\ \text{XRP}\}$, $D_0 = 40\ \text{XRP}$, $c_{\min} = c_{\text{liq}} = 10000$:

| Order                                | Step 1                                           | Step 2                                           | Total                        |
| ------------------------------------ | ------------------------------------------------ | ------------------------------------------------ | ---------------------------- |
| Largest first (30 XRP, then 10 XRP)  | $\lceil 40 \cdot 0.01 \rceil = 0.40\ \text{XRP}$ | $\lceil 10 \cdot 0.01 \rceil = 0.10\ \text{XRP}$ | **0.50 XRP** (500,000 drops) |
| Smallest first (10 XRP, then 30 XRP) | $\lceil 40 \cdot 0.01 \rceil = 0.40\ \text{XRP}$ | $\lceil 30 \cdot 0.01 \rceil = 0.30\ \text{XRP}$ | **0.70 XRP** (700,000 drops) |

A 40% difference in what the first-loss capital absorbed, on **identical losses**. Three independent agreements: the iterative simulation in `coverForOrder()`, the closed form above, and the ledger itself.

### Fairness score

Observed consumption placed between the two extremes:

$\phi = \frac{T_{\text{observed}} - T_{\min}}{T_{\max} - T_{\min}}, \qquad \phi \in [0, 1]$

$\phi = 0.0000$ is investor-worst (the sequence that minimised cover consumed, leaving the maximum loss for depositors). $\phi = 1.0000$ is investor-best (the sequence that maximised cover consumed on the broker's behalf). $\phi$ is defined as $1$ when $T_{\max} = T_{\min}$, meaning the ordering could not have changed anything.

```js theme={null}
// src/history.mjs  (inside analyseOrdering)
fairness: span.isZero() ? '1.0000' : actual.minus(worst).div(span).toFixed(4),
```

<Note>
  Read the JSON field names carefully before comparing. In `GET /api/vaults/{id}/broker-history` the score is the field `fairness`. `worstPossible` is the **smallest** cover number (largest-first order, worst for investors), and `bestPossible` is the **largest**. The names are from the investors' point of view, not the arithmetic's.
</Note>

Reconstructed from ledger history alone for the Kestrel broker, which declared the 30 XRP loan first:

```json theme={null}
"ordering": {
  "applicable": true,
  "defaultCount": 2,
  "actualCoverPaid": "500000",
  "bestPossible": "700000",
  "worstPossible": "500000",
  "costToDepositors": "200000",
  "spread": "200000",
  "fairness": "0.0000",
  "fairOrder": ["10000000", "30000000"]
}
```

The 0.20 XRP of `costToDepositors` is depositor money. Reconstructing it required no privileged access.

***

## 5. Capital Destruction

Every other factor measures current exposure — and a realised loss leaves none behind. When a write-off settles, the asset is removed from `AssetsTotal`, the provision is released, and the book reads clean. Without an explicit memory term, a vault that just wrote off four-fifths of its portfolio would score identically to one that never lost a penny.

$\rho = \max\!\left(0,\; \frac{S - A}{S}\right)$

<Warning>
  $\rho$ is measured against $A$ (total assets), **not** against $A - L$ (assets net of provision). An unrealised loss is a provision against an asset the vault still holds and may recover. Counting it against $\rho$ would punish a manager for disclosing early — precisely the behaviour the rest of the system exists to reward. Only a write-off, which removes the asset from `AssetsTotal` outright, is permanent destruction.
</Warning>

Par is $1.0$ for a closed-ended vault: subscription closes before any lending, so $S$ is capital subscribed measured in asset units, and later subscribers join at the same unit value with no profit or loss yet accrued. This identity does not hold for an open-ended vault and $\rho$ would need rethinking in that case.

```js theme={null}
// src/score.mjs  (inside deriveScoreInputs)
const realisedLossPct = shares.isZero() || assetsTotal.gte(shares)
  ? D(0)
  : shares.minus(assetsTotal).div(shares).times(100)
```

**Measured.** A vault that took 50 XRP, defaulted on 40, and holds 10.5: $\rho = 79\%$, $\Delta = -14$ from AAA, composite **B**. Without this term the same vault scored AAA, identical to one that never lost a penny — and because the book sorts worst-first, the most damaged facility sorted last. That was observed on Devnet, not hypothesised.

***

## 6. Composite Grade by Ordinal Notching

Orma grades are **ordinal and rule-based, never a weighted sum.** A weighted average lets a strong factor compensate for a broken one — and that is not the question an investor is asking.

### The ladder

Let $\mathcal{L} = [\text{AAA}, \text{AA+}, \ldots, \text{D}]$, $|\mathcal{L}| = 20$, ordered best to worst, and $\text{idx}(g)$ the position of grade $g$:

$\text{notch}(g, \Delta) = \mathcal{L}\big[\operatorname{clamp}(\text{idx}(g) - \Delta,\; 0,\; 19)\big]$

```js theme={null}
// src/score.mjs
export const LADDER = ['AAA', 'AA+', 'AA', 'AA-', 'A+', 'A', 'A-', 'BBB+', 'BBB', 'BBB-',
                       'BB+', 'BB', 'BB-', 'B+', 'B', 'B-', 'CCC', 'CC', 'C', 'D']

export const notch = (grade, delta) =>
  LADDER[Math.min(LADDER.length - 1, Math.max(0, LADDER.indexOf(grade) - delta))]

export const gradeNumeric = (g) =>
  Math.round((1 - LADDER.indexOf(g) / (LADDER.length - 1)) * 100)
```

A negative $\Delta$ is a downgrade. The ladder runs best to worst, so a worse grade is a higher index — hence the minus sign.

### Dimension bands

Each dimension maps its raw value to an initial band grade before any notching. Bands are evaluated top to bottom; the first threshold the value reaches wins, and D if none does.

<Accordion title="LIQUIDITY — AssetsAvailable / AssetsTotal">
  | Threshold  | Grade |
  | ---------- | ----- |
  | $\ge 0.95$ | AAA   |
  | $\ge 0.85$ | A     |
  | $\ge 0.70$ | BBB   |
  | $\ge 0.50$ | BB    |
  | $\ge 0.25$ | B     |
  | $\ge 0.05$ | CCC   |
  | otherwise  | D     |

  Defined as $1$ when $A = 0$.
</Accordion>

<Accordion title="COVER — first-loss adequacy">
  $\text{adequacy} = \frac{\min\!\big(\lceil D \cdot c_{\min} \cdot c_{\text{liq}} \rceil,\; C\big)}{\max\!\big(L,\; P_{\text{distressed}}\big)}$

  The denominator takes the **larger** of the recognised loss and the distressed principal. Dividing by $L$ alone would grade a vault concealing a large overdue exposure as perfectly covered. **Concealment must never improve a score.**

  | Threshold  | Grade |
  | ---------- | ----- |
  | $\ge 1$    | AA    |
  | $\ge 0.5$  | A-    |
  | $\ge 0.2$  | BBB-  |
  | $\ge 0.05$ | BB-   |
  | $\ge 0.01$ | B-    |
  | otherwise  | D     |

  Defined as $1$ when the denominator is zero (no exposure to absorb).
</Accordion>

<Accordion title="CONCENT — concentration">
  Graded on $1 - \text{concentration}$, where $\text{concentration} = \max_i(p_i) / D$.

  | Threshold (on $1 -$ concentration) | Grade |
  | ---------------------------------- | ----- |
  | $\ge 0.8$                          | AA    |
  | $\ge 0.6$                          | A-    |
  | $\ge 0.4$                          | BBB-  |
  | $\ge 0.2$                          | BB-   |
  | otherwise                          | D     |

  Note: the API reports `concentration` raw (higher is worse) with `worseIsHigher: true`. The grading is done internally on the complement.
</Accordion>

<Accordion title="RECOG — recognition lag">
  A direct cut on seconds, not a ratio:

  | Threshold | Grade |
  | --------- | ----- |
  | $\le 0$   | AAA   |
  | $< 60$ s  | A     |
  | $< 300$ s | BBB   |
  | $< 900$ s | BB    |
  | otherwise | CCC   |

  `recogLagSeconds` is the maximum seconds any loan with status `overdue` or `defaultable` has been overdue without being declared, floored at zero.
</Accordion>

<Accordion title="DEADLINE — redemption cliff">
  $\text{shortfall} = \max\!\Big(0,\; (A - L) - \big(A_v + R_{\text{performing}} + [P_{\text{distressed}} > 0] \cdot C_{\text{rec}}\big)\Big)$

  where $R_{\text{performing}}$ is the total contractual repayment of performing loans (status `current` or `due_soon`) and $C_{\text{rec}} = \min\!\big(\lceil D \cdot c_{\min} \cdot c_{\text{liq}} \rceil,\ C\big)$.

  Graded on $1 - \text{shortfall\%}/100$:

  | Threshold            | Grade |
  | -------------------- | ----- |
  | $= 1$ (0% shortfall) | AAA   |
  | $\ge 0.98$           | AA    |
  | $\ge 0.90$           | A     |
  | $\ge 0.75$           | BBB   |
  | $\ge 0.50$           | BB    |
  | $\ge 0.25$           | B     |
  | $\ge 0.10$           | CCC   |
  | otherwise            | D     |

  rippled refuses any `LoanSet` whose maturity overruns the vault's `RedemptionDate`, so every performing loan is contractually repaid before redemption. A projected shortfall is therefore exactly the non-performing book net of recoverable cover. **Being fully lent is not a distress signal.**
</Accordion>

### Notch rules

The composite **anchors on DEADLINE** — for a fixed-term facility the headline question is whether claims can be met at redemption — then notches from there:

| Condition                                     | $\Delta$ |
| --------------------------------------------- | -------- |
| Realised capital destruction $\ge 75\%$       | $-14$    |
| Realised capital destruction $\ge 50\%$       | $-12$    |
| Realised capital destruction $\ge 25\%$       | $-8$     |
| Realised capital destruction $\ge 10\%$       | $-5$     |
| Realised capital destruction $\ge 2\%$        | $-3$     |
| Realised capital destruction $> 0\%$          | $-1$     |
| Recognition lag $> 300$ s                     | $-2$     |
| Recognition lag $> 0$ s                       | $-1$     |
| Largest exposure $\ge 75\%$ of the book       | $-1$     |
| Liquidatable cover $< 5\%$ of recognised loss | $-1$     |
| In Redemption phase with liquidity exhausted  | $-2$     |

The two recognition-lag rules are exclusive (the larger wins). The realised-destruction bands are also exclusive. Every notch applied is emitted in `notchTrace` on the API response, with `from`, `rule`, `delta`, and `to`. That trace is the answer to "how did you choose your weights" — there are no weights, there are notches, and each one is named.

### Worked example — Calder Structured Credit III

$A = 51{,}000{,}000$, $A_v = 41{,}000{,}000$, $L = 10{,}000{,}000$, $S = 51{,}000{,}000$, $D = 10{,}000{,}000$, $C = 5{,}000{,}000$, $c_{\min} = c_{\text{liq}} = 10000$, one impaired loan of $p = 10{,}000{,}000$, phase: Investment.

| Step                    | Value                                                               | Grade |
| ----------------------- | ------------------------------------------------------------------- | ----- |
| Liquidity               | $41/51 = 0.8039$                                                    | BBB   |
| Liquidatable cover      | $\lceil 10{,}000{,}000 \times 0.01 \rceil = 100{,}000$              | —     |
| Cover adequacy          | $100{,}000 / \max(10{,}000{,}000,\ 10{,}000{,}000) = 0.0100$        | B-    |
| Concentration           | $10{,}000{,}000 / 10{,}000{,}000 = 1.0000$                          | D     |
| Recognition lag         | 0 s (loan already declared impaired)                                | AAA   |
| Claims                  | $51{,}000{,}000 - 10{,}000{,}000 = 41{,}000{,}000$                  | —     |
| Liquidity at redemption | $41{,}000{,}000 + 0 + 100{,}000 = 41{,}100{,}000$                   | —     |
| Shortfall               | $\max(0,\ 41{,}000{,}000 - 41{,}100{,}000) = 0,\ \text{so}\ 0.00\%$ | AAA   |

Notching: anchor AAA. $\rho = 0$ because $A \ge S$, no capital-destruction notch. Concentration $1.0 \ge 0.75$, $-1$ → AA+. Adequacy $0.0100 < 0.05$, $-1$ → **AA**. Numeric: $\operatorname{round}(100 \cdot (1 - 2/19)) = 89$.

***

## 7. Conduct Score

The conduct score is a separate ordinal assessment of the **manager**, not of the book. It starts at 100 and is clamped to $[0, 100]$:

| Observation                                            | Penalty    |
| ------------------------------------------------------ | ---------- |
| $\phi < 0.25$ — sequence close to investor-worst order | $-35$      |
| $0.25 \le \phi < 0.75$ — mixed ordering                | $-15$      |
| A loss written off with no prior impairment            | $-20$ each |
| A withdrawal of first-loss capital                     | $-10$ each |

Mapped to letter grades: $A \ge 90$, $B \ge 75$, $C \ge 55$, $D \ge 35$, $E$ below.

```js theme={null}
// src/history.mjs  (inside reputation)
score = Math.max(0, Math.min(100, score))
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 55 ? 'C' : score >= 35 ? 'D' : 'E'
```

**Measured on Devnet.** The Kestrel broker scores $100 - 35 - (2 \times 20) = 25$, grade **E**: it declared the largest loan first (fairness $0.0000$), and neither loan was impaired before it was written off.

### Finding codes

<Accordion title="ORDERING_SELF_SERVING">
  Declared losses in an order close to the one that minimises the broker's own first-loss contribution (fairness score $< 0.25$). The choice cost investors the difference between `actualCoverPaid` and `bestPossible`.
</Accordion>

<Accordion title="DEFAULT_WITHOUT_IMPAIRMENT">
  An exposure was written off with no prior impairment — the loss went straight from invisible to realised, with no window for anyone to react. Penalty $-20$ per occurrence.
</Accordion>

<Accordion title="COVER_WITHDRAWN">
  A withdrawal of first-loss capital is on record. Cover taken out ahead of a deteriorating book is the pattern that preceded losses at Maple Finance in 2022. Penalty $-10$ per withdrawal.
</Accordion>

<Warning>
  Reconstructing this history requires a join that is not obvious. `LoanManage` carries no `LoanBrokerID`, only `LoanID`, and an impairment does not touch the `LoanBroker` object at all. Matching on the broker node or a transaction field alone silently drops every impairment. `Loan.FinalFields.LoanBrokerID` in the same metadata closes the join at no extra request. Without that join, every default looks unsignalled — and a rule written to reward disclosure ends up penalising it.
</Warning>

<Note>
  Devnet prunes account history to roughly 29 days, so the conduct score covers a **window**, not a lifetime. The API says so in a `caveat` field on the `reputation` block of `GET /api/vaults/{id}/broker-history`.
</Note>

***

## 8. Pledge Valuation

For $u$ units pledged as collateral, Orma reports both readings so a lender can see what a naive computation would have told them:

$V_{\text{reported}} = u \cdot \text{NAV}_{\text{naive}}$

$V_{\text{held}} = u \cdot \text{NAV}_{\text{held}}$

$\text{overstatement} = V_{\text{reported}} - V_{\text{held}}$

A lender's own haircut $h$ applies to the **honest** value, not the reported one:

$V_{\text{lendable}} = V_{\text{held}} \cdot (1 - h)$

<Tip>
  A haircut absorbs volatility. It does not absorb a misstatement. A 20% haircut applied to a value that is 19.61% overstated leaves the lender roughly where they thought they were starting.
</Tip>

**Measured.** $u = 1{,}000{,}000$ units against $\text{NAV}_{\text{held}} = 0.803922$: reported $1.000000\ \text{XRP}$, held $0.803922\ \text{XRP}$, overstatement $0.196078\ \text{XRP}$.

Two functions compute this, with deliberately different field names because they answer different questions:

<Tabs>
  <Tab title="valuePledge (nav.mjs)">
    Behind `GET /api/mpt/{issuanceId}/nav?units=N`. Takes a NAV document and a unit count; returns `units`, `valueHeld`, `valueReported`, `overstatement`. Returns an `unpriced` string rather than throwing when the document carries no `unitValue`, because a lender's collateral tool must be able to say "I could not value this" rather than crash.

    ```js theme={null}
    // src/nav.mjs
    export function valuePledge(nav, units) {
      const uv = nav?.unitValue
      if (!uv || uv.held === undefined || uv.reported === undefined) {
        return {
          units: num(units).toFixed(0),
          valueHeld: null,
          valueReported: null,
          overstatement: null,
          unpriced: 'the document at the pointer carries no unitValue, so this pledge cannot be valued',
        }
      }
      const u = num(units)
      const held = u.times(num(uv.held))
      const reported = u.times(num(uv.reported))
      return {
        units: u.toFixed(0),
        valueHeld: held.toFixed(0),
        valueReported: reported.toFixed(0),
        overstatement: reported.minus(held).toFixed(0),
      }
    }
    ```
  </Tab>

  <Tab title="valuePledge (collateral.mjs)">
    Behind `GET /api/vaults/{id}/collateral`. Takes vault collateral state; returns `shares`, `valueNaive`, `valueCorrect`, `overstatement`, `overstatementPct`, plus `maxLendable` after the caller's `haircut` query parameter.

    Both functions multiply the published six-decimal NAV strings, so a pledge value is reproducible to the drop from the API document alone.
  </Tab>
</Tabs>

***

## 9. Oracle Encoding (XLS-47)

Orma publishes six `PriceData` entries per vault as a native XLS-47 Price Oracle — one per dimension. The base asset is derived from the vault ID; the quote asset is the dimension code.

| Code  | Dimension                                | Scale |
| ----- | ---------------------------------------- | ----- |
| `NAV` | Loss-adjusted assets per unit            | 6     |
| `HDL` | Composite grade, numeric, divided by 100 | 4     |
| `LIQ` | Liquidity ratio                          | 4     |
| `COV` | First-loss adequacy                      | 4     |
| `CNC` | Concentration (raw — higher is worse)    | 4     |
| `DDL` | Redemption cliff, percent divided by 100 | 4     |

Everything except NAV is clamped to $[0, 1]$. NAV is a price and may exceed 1.

A stored value $v$ at scale $s$ decodes to $v \cdot 10^{-s}$. `AssetPrice` is a `UInt64` serialised as **hexadecimal**, so writing `"100"` means 256, not one hundred:

```js theme={null}
// src/oracle.mjs
export const encodePrice = (n) => BigInt(n).toString(16).toUpperCase()
export const decodePrice = (hex) => BigInt(`0x${hex}`)
```

The base asset uses the first 40 hex characters of the vault ID, with the leading byte forced non-zero:

```js theme={null}
// src/oracle.mjs
export function vaultToBaseAsset(vaultId) {
  let hex = vaultId.replace(/^0x/i, '').toUpperCase().slice(0, 40).padEnd(40, '0')
  if (/^0{2}/.test(hex)) hex = 'A' + hex.slice(1)
  return hex
}
```

Two traps motivate that last line: an all-zero currency code means XRP, and a code whose bytes 12–14 spell ASCII is rendered back by rippled as a three-character ticker symbol rather than a hex string.

### Decoded from a live oracle object

Oracle object `03C7B0E151129510F11FF92CCE8FBB3E7AD3C55B25EDA6EB414324E3E995078A`, document id 3, for Calder Structured Credit III:

| Code  | Raw hex | Scale | Decoded  | Check                                     |
| ----- | ------- | ----- | -------- | ----------------------------------------- |
| `NAV` | `c4452` | 6     | 0.803922 | $\text{0xC4452} = 803922$                 |
| `HDL` | `22c4`  | 4     | 0.8900   | $\text{0x22C4} = 8900$; grade 89 over 100 |
| `LIQ` | `1f67`  | 4     | 0.8039   | $\text{0x1F67} = 8039$                    |
| `COV` | `64`    | 4     | 0.0100   | $\text{0x64} = 100$                       |
| `CNC` | `2710`  | 4     | 1.0000   | $\text{0x2710} = 10000$                   |
| `DDL` | `0`     | 4     | 0.0000   | Shortfall is zero                         |

### Four publication invariants

Each of these corrupts data silently rather than erroring:

<Accordion title="OracleSet is not a merge">
  A pair already on the object but omitted from the transaction is kept with its `AssetPrice` and `Scale` stripped. Publishing one dimension blanks the other five. The publisher always resends all six. There is also no way to remove a pair, so reusing a document ID for a different set of assets accumulates stale pairs until `OracleSet` returns `tecARRAY_TOO_LARGE`. The only recovery path is `OracleDelete` then recreate. Document IDs come from an explicit table, never from a hash of the vault ID, because the ID is a `UInt32` scoped to (account, ID) and a collision would silently overwrite another vault's scores.
</Accordion>

<Accordion title="LastUpdateTime must strictly increase">
  Equal or lower gives `tecINVALID_UPDATE_TIME`, burning a fee and a sequence number. The publisher uses ledger close time clamped to `Date.now()` and skips a tick rather than post ahead of the clock.
</Accordion>

<Accordion title="LastUpdateTime in the future bricks the object">
  A `LastUpdateTime` in the future bricks the object until wall clock catches up. Never add a safety margin.
</Accordion>

<Accordion title="AssetPrice is hex, not decimal">
  `AssetPrice` is a `UInt64` serialised as hexadecimal. Writing the decimal string `"803922"` would be interpreted as hex $803922_{16} = 8,404,258$, giving a NAV nine orders of magnitude too large. Always encode with `BigInt(n).toString(16).toUpperCase()`.
</Accordion>

<Note>
  `LastUpdateTime` is UNIX epoch, not Ripple epoch.
</Note>

<Tip>
  XLS-47 is live on Mainnet today, unlike the lending amendments. `get_aggregate_price` computes a median and standard deviation across independent publishers inside the ledger — that is the point of publishing here rather than serving a number over HTTP. A second publisher who disagrees changes the aggregate without asking Orma's permission. Orma reports `aggregate: null` honestly while it is the only publisher.
</Tip>

***

## Checking This Yourself

Start from the valuation document, which names the ledger state it was computed from precisely so you do not have to trust it:

```bash theme={null}
curl -s http://localhost:8787/api/vaults/5763707D11EA19D1B5FF04E4EBA4F9336057955CDE65B725D1FF3EF2A96CB0E5/nav
```

```json theme={null}
{
  "unitValue": {
    "held": "0.803922",
    "reported": "1.000000",
    "divergenceBps": 1961,
    "basis": "assets net of recognised loss, divided by units outstanding"
  },
  "provenance": {
    "network": "devnet",
    "source": "devnet",
    "assetsTotal": "51000000",
    "lossUnrealized": "10000000",
    "unitsOutstanding": "51000000",
    "recompute": "(assetsTotal - lossUnrealized) / unitsOutstanding",
    "buildVersion": "3.4.0-rc5"
  }
}
```

Recompute both readings and the divergence from those three strings in decimal arithmetic, not in floats:

```js theme={null}
import Decimal from 'decimal.js'
Decimal.set({ precision: 40 })

const p = {
  assetsTotal:    '51000000',
  lossUnrealized: '10000000',
  unitsOutstanding: '51000000',
}
const A = new Decimal(p.assetsTotal)
const L = new Decimal(p.lossUnrealized)
const S = new Decimal(p.unitsOutstanding)

const naive = A.div(S)               // 1
const held  = A.minus(L).div(S)      // 0.80392156862745098039...
const bps   = naive.minus(held).div(naive).times(10000)

console.log(naive.toFixed(6), held.toFixed(6), Number(bps.toFixed(0)))
// 1.000000  0.803922  1961
```

Then verify the provenance against the ledger directly — the step that makes the exercise non-circular:

```js theme={null}
import { Client } from 'xrpl'

const client = new Client('wss://s.devnet.rippletest.net:51233')
await client.connect()
// Note: the parameter is vault_id, not vault (see XLS-65 section 3.9.1)
const r = await client.request({
  command: 'vault_info',
  vault_id: '5763707D11EA19D1B5FF04E4EBA4F9336057955CDE65B725D1FF3EF2A96CB0E5',
})
console.log(r.result.vault.AssetsTotal, r.result.vault.LossUnrealized)
await client.disconnect()
```

`AssetsTotal` and `LossUnrealized` should equal `provenance.assetsTotal` and `provenance.lossUnrealized` as of the `ledgerIndex` on the response envelope. If `LossUnrealized` is absent, that means zero — rippled omits any field equal to its type default, which is the same rule that makes the first impairment invisible to a diffing indexer.

<Steps>
  <Step title="Verify the grade">
    Every notch is in `notchTrace` on `GET /api/vaults/{id}`, with the grade before and after. Walk it against the band tables in [Section 6](#6-composite-grade-by-ordinal-notching) and the notch rules table.
  </Step>

  <Step title="Verify the cover arithmetic">
    `GET /api/vaults/{id}/broker-history` lists every default with `debtBefore` and `coverConsumed`. Check each against $\lceil D \cdot c_{\min} \cdot c_{\text{liq}} \rceil$ using the rates on the broker, then check the total against `coverForOrder()` run over the same principals in both extreme orders.
  </Step>

  <Step title="Verify the oracle">
    Fetch the object by its index on the Devnet explorer and decode `AssetPrice` yourself with `BigInt('0x' + raw)` divided by $10^{\text{Scale}}$. It should agree with the API to the last digit, because both come from the same snapshot.
  </Step>
</Steps>

<Note>
  Everything on this page runs on Devnet at `wss://s.devnet.rippletest.net:51233` (network ID 2), because XLS-65 vaults and XLS-66 lending are not yet on Mainnet. The oracle publication via XLS-47 is the one component with a production path today.
</Note>
