The NAV pointer: a self-describing collateral instrument

A vault share whose own MPTokenMetadata names where its honest valuation lives, so a second lender can price the collateral from the token alone.

A vault share is a redeemable claim on a credit book. Its value moves every time the manager recognises a loss, and the token itself carries no price field: an MPTokenIssuance records who issued it and how many units are outstanding, nothing about what a unit is worth. So when someone pledges vault shares to a second lender, that lender has an identifier and no way to price it.

Orma writes a pointer into the share token's own MPTokenMetadata. Not a value, a URL template. Whoever holds the token reads its metadata off the ledger, substitutes the id they already hold into the template, follows it, and gets a valuation computed from public ledger state with the inputs named so they can recompute it themselves.

This is the same asymmetry the rest of Orma measures, moved to where it bites hardest. The party who decides when a vault recognises a loss is the party whose first-loss obligation shrinks by delaying it. A lender pricing pledged shares on the reported figure is lending against that delay. On the facility below the gap is 19.61%.

The idea came from a Ripple developer advocate at the hackathon on the Saturday afternoon. It was measured on Devnet within minutes of him saying it, and the results that came back are what shaped the design.

Two constraints that decide the whole design

1. The metadata is write-once, permanently

Three transactions, three results, all on Devnet:

attemptresult
MPTokenMetadata set on VaultCreatetesSUCCESS, and it persists on the share MPTokenIssuance
VaultSet updating it laterrejected at deserialization: Field 'MPTokenMetadata' found in disallowed location
MPTokenIssuanceSet submitted by the vault ownertecNO_PERMISSION

The third one is the interesting one. The issuer of a vault share is not the vault owner, it is the vault's pseudo-account, which carries lsfDisableMaster and has no regular key. There is no key in existence that can sign an update. Not the owner's, not ours, not the validators'. The field is written once at vault creation and is then frozen for the life of the vault.

Warning

A number written into share metadata is a lie within one ledger close. There is no

correction path, no admin override and no migration. If you are tempted to put a NAV, an

audit date or a grade in there, this is the constraint that stops you.

That is not fatal, it is disciplining. A pointer stays true as long as the endpoint behind it stays up, and the endpoint can be recomputed every four seconds.

2. The pointer cannot name the thing it describes

MPTokenMetadata is only settable on VaultCreate. That transaction is submitted before the vault exists, so at the moment the pointer has to be composed:

So a self-referential URL is impossible. What we write instead is a template with a placeholder:

http://localhost:8787/api/mpt/{mpt_issuance_id}/nav

The reader supplies the missing half. Anyone reading this metadata got to it by holding the token, so they already have the issuance id: the pointer only has to name the service and the substitution. That turns an unresolvable reference into a trivial one.

{mpt_issuance_id} is exported as MPT_PLACEHOLDER from src/metadata.mjs and is also written into the metadata itself as orma.nav_url_param, so a consumer who has never read this page can still see which substring is meant to be replaced.

The metadata we actually write

XLS-89 fields first, in the order rippled names them, then one namespaced block of our own. This is the exact object on the ledger for Calder Structured Credit III, copied from demo/metadata-vault.json:

{
  "ticker": "ORMA-CSC3",
  "name": "Calder Structured Credit III",
  "icon": "http://localhost:8787/assets/mark.svg",
  "asset_class": "rwa",
  "issuer_name": "Orma",
  "desc": "Redeemable claim on a closed-ended private credit facility. Valuation is net of recognised loss.",
  "orma": {
    "v": 1,
    "instrument": "vault-share",
    "nav_url": "http://localhost:8787/api/mpt/{mpt_issuance_id}/nav",
    "nav_url_param": "{mpt_issuance_id}",
    "nav_basis": "assets net of recognised loss, divided by units outstanding",
    "doc": "https://orma.credit/nav"
  }
}

502 bytes of UTF-8, 1004 hex characters on the wire, against a ledger ceiling of 1024 bytes. encodeShareMetadata() throws rather than submit a blob over that ceiling, because over the ceiling is a malformed transaction and just under it is a permanent record you cannot trim later.

Why XLS-89 fields at all. rippled volunteers a warning, unprompted, when metadata is not XLS-89 shaped: it says the token "might not be discoverable by Explorers and Indexers" and lists the fields it expects, ticker, name, icon, asset_class and issuer_name. A vault share created the obvious way, with no metadata, is invisible to every indexer that reads the standard. Five fields is a cheap price for being listed.

Why asset_class: "rwa". The permitted values are rwa, memes, wrapped, gaming, defi and other. None of them describes a yield-bearing pooled claim. rwa is the closest true statement, since a private credit facility is a real world asset, and the precise thing lives in orma.instrument as vault-share. xls89Report() returns that caveat on the wire as taxonomyNote, so a consumer reading asset_class is told it is being given less information than the field appears to carry.

Building and encoding it, from src/demo/bake-metadata.mjs:

import { buildShareMetadata, encodeShareMetadata, MPT_PLACEHOLDER } from '../metadata.mjs'

const navTemplate = `${BASE.replace(/\/+$/, '')}/api/mpt/${MPT_PLACEHOLDER}/nav`
const meta = buildShareMetadata({
  ticker: 'ORMA-CSC3',
  name: 'Calder Structured Credit III',
  navUrl: navTemplate,
  icon: `${BASE.replace(/\/+$/, '')}/assets/mark.svg`,
  issuerName: 'Orma',
  desc: 'Redeemable claim on a closed-ended private credit facility. Valuation is net of recognised loss.',
})
const blob = encodeShareMetadata(meta)

await c.submitAndWait({
  TransactionType: 'VaultCreate', Account: owner.address, Asset: { currency: 'XRP' },
  VaultKind: 1, SubscriptionDate: SUB, RedemptionDate: RED, WithdrawalPolicy: 1,
  MPTokenMetadata: blob,
}, { wallet: owner, autofill: true })

VaultKind: 1 with SubscriptionDate and RedemptionDate is not optional here. A vault that is not closed-ended cannot have a LoanBroker at all, so every vault whose shares are worth valuing this way is a fixed-term fund.

The resolution loop

This is what a second lender runs. They hold an MPT issuance id and nothing else: no relationship with the facility, no access to its reporting, no reason to trust a figure anyone hands them.

import { Client } from 'xrpl'

const ISSUANCE = '000000014D667775372D5B78E07FFF294678C7F9CE82AFBC'

const c = new Client('wss://s.devnet.rippletest.net:51233')
await c.connect()

// 1. read the MPTokenIssuance off the ledger
const { result } = await c.request({ command: 'ledger_entry', mpt_issuance: ISSUANCE })
const node = result.node

// 2. decode MPTokenMetadata, which is hex-encoded UTF-8
const meta = JSON.parse(Buffer.from(node.MPTokenMetadata, 'hex').toString('utf8'))

// 3. substitute the id you already hold into the template
const url = meta.orma.nav_url.replaceAll(meta.orma.nav_url_param, ISSUANCE)

// 4. follow it
const nav = await (await fetch(url, { headers: { accept: 'application/json' } })).json()

console.log(nav.unitValue.held, nav.unitValue.reported, nav.unitValue.divergenceBps)
// 0.803922 1.000000 1961

await c.disconnect()

Every step before the last is ledger state. The last one is a URL the ledger named. Nothing in the loop requires Orma to be known to the lender in advance, which is the difference between a dashboard, which you have to already know about, and a primitive, which is reachable from the asset itself.

src/nav.mjs implements the same loop as resolveFromIssuance(), with the failure handling a lender's tool actually needs. It never throws. Each step reports what it found, because "this collateral is opaque" is a legitimate and useful answer to give a credit desk, and a much better one than an exception.

Running it through the API

curl -s "http://localhost:8787/api/mpt/000000014D667775372D5B78E07FFF294678C7F9CE82AFBC/resolve"

Captured response, trimmed to the loop itself:

{
  "serverTime": "2026-09-12T22:20:22Z",
  "ledgerIndex": 5263343,
  "issuanceId": "000000014D667775372D5B78E07FFF294678C7F9CE82AFBC",
  "issuer": "r3hE8HanpccSZdgmeHCfYFEkwxFjdDmmvt",
  "unitsOutstanding": "51000000",
  "resolved": true,
  "navUrl": "http://localhost:8787/api/mpt/000000014D667775372D5B78E07FFF294678C7F9CE82AFBC/nav",
  "steps": [
    { "step": "read MPTokenIssuance", "ok": true,
      "detail": "issuer r3hE8HanpccSZdgmeHCfYFEkwxFjdDmmvt, 51000000 units outstanding" },
    { "step": "decode metadata", "ok": true, "detail": "valid JSON" },
    { "step": "XLS-89 conformance", "ok": true, "detail": "asset_class rwa" },
    { "step": "find valuation pointer", "ok": true,
      "detail": "http://localhost:8787/api/mpt/000000014D667775372D5B78E07FFF294678C7F9CE82AFBC/nav" },
    { "step": "follow pointer", "ok": true, "detail": "unit value 0.803922" }
  ]
}

Five steps, all green. The issuer on that first line, r3hE8HanpccSZdgmeHCfYFEkwxFjdDmmvt, is the vault pseudo-account: the same account whose lsfDisableMaster flag makes the metadata permanent.

The valuation document that comes back is the one described in the API reference, and the pointer route serves it keyed by share token rather than by vault id, because the issuance id is the only identifier a holder has. It carries its own inputs. Trimmed to the valuation and its provenance:

{
  "schema": "orma.nav/1",
  "unitValue": {
    "held": "0.803922",
    "reported": "1.000000",
    "divergenceBps": 1961,
    "basis": "assets net of recognised loss, divided by units outstanding"
  },
  "provenance": {
    "network": "devnet",
    "assetsTotal": "51000000",
    "lossUnrealized": "10000000",
    "unitsOutstanding": "51000000",
    "recompute": "(assetsTotal - lossUnrealized) / unitsOutstanding",
    "buildVersion": "3.4.0-rc5"
  }
}
Tip

provenance exists so a lender never has to take our word for anything. Those three

integers are on the Vault ledger entry and the share MPTokenIssuance. Read them

yourself and apply the one-line formula in recompute.

What the pledge is worth

The facility was built to have something worth saying: it takes 51 XRP, lends 10, and the loan is impaired once past due. So $A = 51{,}000{,}000$ drops, $L = 10{,}000{,}000$ drops, $S = 51{,}000{,}000$ units, and the two readings of the same vault are

$$\text{NAV}_{\text{naive}} = \frac{A}{S} = 1.000000, \qquad \text{NAV}_{\text{held}} = \frac{A - L}{S} = 0.803922$$

A pledge of 1,000,000 units, which is the call GET /api/mpt/{id}/nav?units=1000000 or valuePledge(nav, '1000000'):

linedropsXRP
value on the reported figure10000001.000000
value on the honest figure8039220.803922
overstatement avoided1960780.196078

19.61% of the stated collateral value was not there. A haircut does not fix this: a haircut $h$ is applied to the held value, $V_{\text{lendable}} = V_{\text{held}}(1-h)$, and absorbs volatility in a number that is correct. It does not absorb a number that is wrong.

Note

The impairment on this facility is transaction

BD18EFB94490CA540E7A9C249971740C1228D1FBC3ED97C9BA9F4908F52D5F4F on Devnet.

PreviousFields records only fields the object already had, and a healthy vault carries no

LossUnrealized, so the impairment that creates it emits an empty PreviousFields and an

indexer that diffs metadata sees no change at all. That behaviour is captured on a separate

facility, transaction

075FE6D2E0F29919AF477A2A8F581A680805A006138BB967D8434611E49229C3 in

demo/indexer-race.json, where PreviousFields is empty while FinalFields carries a

LossUnrealized of 10,000,000 drops. It is why the reported figure stays at 1.000000

wherever it is read by diffing.

All arithmetic here is string in, string out, at 40 significant digits with decimal.js. No monetary value in Orma passes through Number() or parseFloat.

When the loop fails, and what the lender is told

Every one of these is a normal outcome, not an error, and each is reported as a failed step with the rest of the document intact.

what happenedwhat the resolver reports
the id is not an MPTokenIssuancestep 1 fails with the rippled error, resolved: false
MPTokenMetadata is absentno metadata on the issuance
the blob is not valid hexmetadata is not valid hex, first 64 characters echoed back
the blob is plain text, not JSONmetadata is not JSON, so it is not XLS-89 shaped. XLS-89 is not mandatory, so this is legal and common
JSON, but no orma.nav_urlno orma.nav_url in the metadata, resolved: false, and the metadata is still returned
the pointer is a template and no id was suppliedthe pointer is not followed. Requesting a literal {mpt_issuance_id} would be a confusing 404 at best
the pointer uses a scheme other than http or httpsignored. This value was written by a party we do not control and it reaches a fetch
the endpoint does not answer within 2500 mstimeout, via AbortController
the endpoint returns a non-2xxHTTP <status>
the endpoint returns 200 with no unitValueanswered, but the document carries no unitValue

That last row is not hypothetical. An early version of the pointer template resolved to the diagnostic route rather than to the valuation route, which returned a perfectly good 200 carrying steps and no unitValue, and crashed the caller that tried to price a pledge with it. valuePledge() now returns an explicit unpriced string instead of a number:

{
  "units": "1000000",
  "valueHeld": null,
  "valueReported": null,
  "overstatement": null,
  "unpriced": "the document at the pointer carries no unitValue, so this pledge cannot be valued"
}

The two routes are kept separate for the same reason. /api/mpt/{id}/nav is what the template resolves to and never follows a pointer itself, because a valuation reachable only by walking a pointer to itself is a loop. /api/mpt/{id}/resolve is the diagnostic that walks the loop and reports every step, and accepts ?resolve=false to stop at the pointer without following it. Both send open CORS headers: the entire point is that a party with no relationship to us can call them, and both are read-only views of public ledger state.

Honest limits

Devnet only. XLS-65 vaults and XLS-66 lending are not on Mainnet, so everything here runs against wss://s.devnet.rippletest.net:51233, rippled 3.4.0-rc5, with xrpl 5.2.0 on Node 24.13.0. Nothing about the mechanism depends on Devnet, but nothing about it has been exercised under Mainnet load either.

The base URL in the baked facility is http://localhost:8787. That is what a hackathon demo can honestly commit to. The field is permanent, so in production the base must be a hostname you are willing to keep serving for the full life of the vault, on https. Writing a host you might move off is the one mistake this design cannot recover from.

The icon in that blob does not resolve. http://localhost:8787/assets/mark.svg is written into the permanent metadata, and the reader serves thirteen JSON routes and no static files, so that URL is a 404 today. Nothing in the resolution loop reads icon. It is an XLS-89 discoverability field for explorers, and resolveFromIssuance() never fetches it, so the valuation path is unaffected. It is stated here because it is the same permanence trap as the base URL, caught one field later, and it cannot be corrected on this facility.

The pointer is a pointer. It moves the trust question from "do you believe this number" to "do you believe this host", and provenance is what shrinks that second question: the three ledger integers and the formula are in every response, so a lender who distrusts the host entirely can still read the same vault themselves and check. The pointer's value is that it tells them where to look and what basis to use, not that it is authoritative.

There is no global index from an mpt_issuance_id to the escrows holding it. You can enumerate collateral pledged in your favour by scanning your own account objects, but you always have to start from an account.

A share token can point anywhere. Nothing stops a vault owner writing their own endpoint into their own share metadata and serving whatever number they like. The design does not assume otherwise. What it provides is that the pointer is public, permanent and readable by everyone, so a lender can see whose valuation they are being asked to accept before they accept it, and a competing valuation of the same vault is one vault_info call away.

The capture in demo/metadata-vault.json shows step 5 failing with HTTP 404. That is the bake script, not a bug: it writes the new facility to demo/ and the API reads that directory at startup, so the first run resolves the pointer correctly and finds a service that has not yet been restarted. The ledger half of the loop, which is the half that had to be proven, closes in that same capture. The sample above is the same facility after the restart.

The facility

Everything on this page is one live Devnet facility:

labelCalder Structured Credit III
tickerORMA-CSC3
vault5763707D11EA19D1B5FF04E4EBA4F9336057955CDE65B725D1FF3EF2A96CB0E5
loan broker1C160EFFCA8208DFD8CE9B065A3C3C41CCA80758FBC3099BE27AACFBD1478173
share issuance000000014D667775372D5B78E07FFF294678C7F9CE82AFBC
share issuer (vault pseudo-account)r3hE8HanpccSZdgmeHCfYFEkwxFjdDmmvt
vault ownerrGMo2ke9of3GEqJwWAzDmFjrm6SwYowTsB
impairmentBD18EFB94490CA540E7A9C249971740C1228D1FBC3ED97C9BA9F4908F52D5F4F

Rebuild it from scratch with node src/demo/bake-metadata.mjs, which funds two wallets, creates the vault with the metadata attached, deposits 51 XRP, originates and impairs a 10 XRP loan, then runs the resolution loop and prints every step.

Orma — built at the XRPL Lending Protocol Hackathon, Paris, 12-13 September 2026, by Alexandre Lemiere and Andrea Gonzalez. Devnet only.