Orma
Credit measurement, publication and enforcement for XLS-65 vaults and XLS-66 lending on the XRP Ledger.
Orma reads the solvency of an XLS-65 vault from public ledger state, publishes the reading as a native XLS-47 Oracle object and as a pointer inside the share token's own metadata, and lets an independent third party turn that reading into an XLS-70 credential that a permissioned domain enforces at deposit time.
It was built at the De Vinci Blockchain XRPL Lending Protocol Hackathon in Paris, 12 and 13 September 2026, by Alexandre Lemiere (backend, protocol) and Andrea Gonzalez (frontend). Everything described here runs against XRPL Devnet, rippled 3.4.0-rc5, because the lending amendments are not on Mainnet. XLS-47 is the one component with a production path today.
A loss nobody was told about
A closed-ended lending vault called Meridian Trade Finance I takes 51 XRP of subscriptions and lends 10 XRP through its LoanBroker. The borrower stops paying. Once the loan is past due the manager impairs it, which is the correct and honest thing to do: it recognises the loss against the vault before anyone redeems.
Here is the Vault ledger entry before and after, in drops:
| field | before | after |
|---|---|---|
AssetsTotal | 51000000 | 51000000 |
AssetsAvailable | 41000000 | 41000000 |
LossUnrealized | 0 | 10000000 |
MPTokenIssuance.OutstandingAmount | 51000000 | 51000000 |
Two readings of the same object. The first divides assets by units:
$$\text{NAV}_{\text{naive}} = \frac{A}{S} = \frac{51{,}000{,}000}{51{,}000{,}000} = 1.000000$$
The second nets out the loss the manager has already recognised:
$$\text{NAV}_{\text{held}} = \frac{A - L}{S} = \frac{51{,}000{,}000 - 10{,}000{,}000}{51{,}000{,}000} = 0.803922$$
The gap is 1961 basis points, measured against the reported figure. A holder who sells, redeems or pledges a unit at par hands over 1.000000 for a claim on 0.803922, so 19.61% of the price is backed by nothing the vault holds.
Now the part that makes this more than an arithmetic footnote. The impairment transaction is 075FE6D2E0F29919AF477A2A8F581A680805A006138BB967D8434611E49229C3, result tesSUCCESS. Its metadata for the Vault node reads:
{
"ledgerIndex": "864C5A2DDCED78C6198B0703169D533B747A56E5F9398D832033F79E37E8C835",
"previousFields": {},
"finalFields": {
"AssetsTotal": "51000000",
"AssetsAvailable": "41000000",
"LossUnrealized": "10000000"
}
}
previousFields is empty. PreviousFields records a previous value only for a field the object already had, and a healthy vault carries no LossUnrealized. Under cash-basis accounting (LEVersion = 1) an impairment touches nothing else on the Vault object: it does not move AssetsTotal and it does not move AssetsAvailable. So there is no field left to diff. An indexer that builds vault state by applying transaction metadata deltas is told nothing at all, and keeps serving a NAV of 1.000000.
The first impairment of a previously healthy vault is invisible to metadata diffing.
Every subsequent one is visible, because LossUnrealized is no longer 0. The single
event that matters most is the one that disappears.
The associated Loan object does change: its Flags go from 0 to 131072, which is lsfLoanImpaired (0x20000). An indexer watching loans would catch it. But the money lives on the Vault, and it is the Vault node that emits nothing.
Orma's reader therefore never diffs PreviousFields. It polls a known list of vault ids every 4000 ms and re-reads each object in full: the Vault, the LoanBrokers that point at it, and every Loan under those brokers. Re-reading costs more requests. It cannot miss an empty diff. See /findings.
One party, two levers
The impairment above was discretionary. Nothing in the protocol obliged the manager to declare it that day rather than a week later, and VaultWithdraw returns tecTOO_SOON for the entire Investment phase, so an investor who suspects a problem cannot leave. That is lever one: when a loss is recognised.
Lever two is in what order losses are realised, and it moves real money. When a loan defaults, the amount of first-loss cover the broker forfeits is
$$T(p) = \min\Big(\big\lceil D \cdot c_{\min} \cdot c_{\text{liq}} \big\rceil,\; p,\; C\Big)$$
where $D$ is the broker's total drawn debt at that moment, not the principal of the loan that defaulted, and both cover rates are in units of $10^{-5}$, so both are divided by $10^5$. Because $D$ is decremented as each default lands, declaring a large exposure first shrinks the base for everything that follows.
Kestrel Bridge Financing II, broker 8DB104980C31CBE9F196508E7D748E8C31FB783CD4E0A3620AE0B476CBDEA3FA, with $c_{\min} = c_{\text{liq}} = 10000$ (0.10 each, so a coefficient of 0.01) and a book of 40 XRP split into a 30 XRP and a 10 XRP exposure:
| order declared | first default | second default | cover consumed |
|---|---|---|---|
| 30 XRP then 10 XRP | $\lceil 40 \cdot 0.01 \rceil = 0.40$ | $\lceil 10 \cdot 0.01 \rceil = 0.10$ | 0.500000 XRP |
| 10 XRP then 30 XRP | $\lceil 40 \cdot 0.01 \rceil = 0.40$ | $\lceil 30 \cdot 0.01 \rceil = 0.30$ | 0.700000 XRP |
Same two losses, same day, 0.2 XRP difference in what the manager's own capital absorbed. The shortfall falls on unit holders. On Devnet the manager declared the 30 XRP exposure first, in transactions FB23EC5313F85FA157D56966E0B0B1665068905988D925C70BB0E7C35C3B221B and DE49DF0D6136B96AD28B253CD32100E5715078FD2D0AE6D1DB747AFECB803D1F, eight seconds apart. Cover went 10.000000 to 9.600000 to 9.500000 XRP.
This generalises. Writing $c = c_{\min} c_{\text{liq}}$ and $D_0$ for the book at the first default, declaring $k$ defaults in order $\sigma$ consumes
$$T(\sigma, k) = c \left[ k D_0 - \sum_{i=1}^{k} (k - i)\, p_{\sigma(i)} \right]$$
Only the sum depends on $\sigma$, and $(k-i)$ decreases in $i$, so by the rearrangement inequality the total is minimised by declaring the largest exposure first. Full derivation and the three independent agreements that check it in /mathematics.
The party who chooses the order is the LoanBroker owner. The LoanBroker owner is necessarily the vault owner: LoanBrokerSet returns tecNO_PERMISSION otherwise, and XLS-75 delegation is temMALFORMED for the whole lending suite. It is also the party whose cover is consumed. So the same account decides when the loss is recognised and in what order it is realised, and both decisions move money from investors to them.
One asymmetry, two levers. Orma's answer is three steps.
Measure
Orma scores five factors independently on a 20-step ordinal ladder from AAA to D: liquidity, first-loss adequacy, concentration, recognition lag, and the redemption cliff. No weights are applied anywhere, because a weighted average lets a strong factor pay for a broken one. The composite anchors on the redemption cliff and is notched down by rule, and the largest single notch, up to $-14$, comes from realised capital destruction $\rho = \max(0, (S - A)/S)$, measured against $A$ and not against $A - L$. A vault that took 50 XRP, defaulted on 40 and holds 10.5 scores $\rho = 79\%$ and grades B. Without that term it graded AAA, because a write-off removes the asset, releases the provision and leaves the book reading clean. Separately, a conduct score assesses the manager rather than the book: the observed default sequence against its best and worst possible orders, plus a penalty for every loss written off with no prior impairment, and one for every withdrawal of first-loss capital. See /mathematics, sections 4 to 6.
Publish
The reading goes on-chain twice. First as a native XLS-47 Price Oracle: six PriceData entries per vault, one per dimension, with the base asset derived from the vault id and quote codes NAV, HDL, LIQ, COV, CNC, DDL. An Oracle is a native ledger object, and get_aggregate_price computes a median across independent publishers inside the ledger, so a second publisher who disagrees with Orma changes the aggregate without asking. A REST endpoint cannot be contested, only trusted. Second, as a pointer inside the share token's own XLS-89 metadata, under an orma key carrying a nav_url template, so a lender holding nothing but the MPT issuance id can resolve to a valuation without knowing Orma exists. See /mathematics, section 8, and /nav-pointer.
Enforce
A grade nobody acts on is a press release. The last step makes it bind: Orma issues an XLS-70 credential of type ORMA-IG to an LP, the LP accepts it (issuance alone grants nothing, a deposit without the accept is still tecNO_AUTH), and a vault owner who wants graded capital names Orma's issuer in a PermissionedDomain and creates the vault with tfVaultPrivate. After that the ledger itself refuses a deposit from an uncredentialed account. Orma signs nothing in that step, is never asked, and cannot refuse to be cited, which is the correct shape for a rating. Revocation is asymmetric on purpose: deleting the credential bounces the next deposit, and leaves the existing position fully withdrawable. A rater who can trap capital is a worse problem than the one being solved. See /gate.
The four facilities on Devnet
All four are live, baked before the demo, and readable by anyone pointed at wss://s.devnet.rippletest.net:51233 (network id 2).
| facility | vault id | what it demonstrates |
|---|---|---|
| Meridian Trade Finance I | 864C5A2DDCED78C6198B0703169D533B747A56E5F9398D832033F79E37E8C835 | The empty PreviousFields. Impaired at 075FE6D2...E49229C3, NAV 1.000000 reported against 0.803922 held, 1961 bp. |
| Kestrel Bridge Financing II | 24EAA01AD4CE70D8ABB4ACB4255DC4C216B8AF3B9E7315DB8052BFC1E5810089 | The ordering lever. Two defaults declared largest first, 0.500000 XRP of cover consumed where the investor-best order would have consumed 0.700000. Conduct grade E, score 25. |
| Calder Structured Credit III | 5763707D11EA19D1B5FF04E4EBA4F9336057955CDE65B725D1FF3EF2A96CB0E5 | The share token pointer. MPT 000000014D667775372D5B78E07FFF294678C7F9CE82AFBC, ticker ORMA-CSC3, metadata resolving to the same 0.803922. |
| Thorne Senior Secured I | 4A5A8E3716D52E334AEB077ADB09456EF4A941CC26021A8EAD37F95B00190DF5 | The gate. Private vault behind domain FFBEC89D98B4E7CF52F4F254235086514A90CED0EC531941BDF747AF40C5A2FB, accepting credential type ORMA-IG from issuer rKQjjU5KFs9RAZCDvYVjcaoVK5gGsCJgkP. |
Try it
With the backend running (npm start, which defaults to port 8787), the valuation route keyed by share token is the one a third party would call, because the share token id is the only identifier a holder has. It is one of three routes that opt into a public cache header for cross-origin callers, alongside the vault-keyed /api/vaults/{vaultId}/nav and the resolver, because all three exist to be called by a party with no relationship to Orma:
curl -s http://localhost:8787/api/mpt/000000014D667775372D5B78E07FFF294678C7F9CE82AFBC/nav
{
"schema": "orma.nav/1",
"instrument": {
"kind": "vault-share",
"vaultId": "5763707D11EA19D1B5FF04E4EBA4F9336057955CDE65B725D1FF3EF2A96CB0E5",
"shareMptId": "000000014D667775372D5B78E07FFF294678C7F9CE82AFBC",
"unitsOutstanding": "51000000",
"vaultKind": "ClosedEnded",
"phase": "Investment",
"redemptionAt": "2026-09-13T15:11:47Z"
},
"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"
}
}
That body is abridged. The full document also carries serverTime, ledgerIndex, asOf, the asset descriptor inside instrument, a valuation block and the assessment.
Add ?units=1000000 to value a specific pledge, so a lender does not have to reimplement decimal arithmetic. The captured impairment metadata is served verbatim at GET /api/indexer-race.
Every monetary quantity on the wire is a decimal string, never a JSON number. XRPL
NUMBER fields carry up to 19 significant digits and Number() destroys them silently.
Orma uses decimal.js at 40 digits of precision throughout.
Where to go next
| if you want to | read |
|---|---|
| understand closed-ended vaults, the three phases and the two levers | /concepts |
| read a vault correctly yourself, including the empty-diff trap | /findings |
| check every formula, the ordering proof and the grade derivation | /mathematics |
| put a valuation pointer inside an MPT's metadata and price a pledge | /nav-pointer |
| gate a vault with credentials and a permissioned domain | /gate |
| run the reader against the four facilities, and read the limits | /quickstart |
| see four worked cases against the live facilities | /use-cases |
| consume Orma from an autonomous agent, with a token id and nothing else | /ai-agents |
What this is not
Orma is Devnet software. XLS-65 and XLS-66 are not enabled on Mainnet, so no part of the
measurement or gating layer can be pointed at production assets today. Two of the three
rails are exceptions on their own: XLS-47 Price Oracles are live on Mainnet now, and so
are the XLS-70 credentials and XLS-80 permissioned domains the gate is built from. What
keeps the gate Devnet-only is the vault underneath it, not the gate.
The grade is advisory and is labelled "advisory": true inside the assessment block of every response that carries a grade. When the grade cannot be computed the block is null and the valuation stands on its own. Orma is not a regulated rating agency and the method version is 1.0.0, which means it has existed for one weekend.
Conduct history is reconstructed from the public transaction record, which Devnet prunes. The window is not a fixed number of days; on 13 October 2026 it reached back about a month. Anything a manager did before that window is not visible, and the API says so in a caveat field rather than pretending the record is complete.
Reconstructing that history needed a join that is not documented anywhere: LoanManage carries no LoanBrokerID and an impairment does not touch the LoanBroker object at all, so filtering account_tx by broker silently drops every impairment. Loan.FinalFields.LoanBrokerID in the same metadata closes it. Before that fix, every default looked unsignalled, and the rule written to reward early disclosure was punishing it. That class of mistake is the reason this page leads with an empty PreviousFields rather than with a score.