Skip to main content
This page has two audiences. The first is an agent consuming Orma: a machine holding a vault share and needing a valuation. GET /api/mpt/:mptId/nav exists for exactly that case — no API key, no account, no prior relationship, no documentation required, because the token itself says where its valuation lives. The second is a developer building on Orma with a coding assistant: XLS-65 and XLS-66 have several behaviours that produce code which runs, returns numbers, and is wrong. An assistant writes those bugs confidently. The checklist in Part 2 is written to be pasted into CLAUDE.md, AGENTS.md, or a system prompt.

Part 1: Consuming Orma from an Agent

Why the token is the entry point

An agent is handed vault shares as collateral. What it has is an MPT issuance id — 48 hex characters. It does not know the vault id, the owner, the broker, or that Orma exists. Share metadata is written once at VaultCreate and can never be corrected, by anyone, for the life of the vault. A price written into that blob would be wrong within one ledger; a pointer stays true. So a pointer is what goes in. The three transactions that establish the immutability are on the NAV pointer page:
The pointer is a template — not by preference, but by necessity. VaultCreate is submitted before the vault and its share issuance exist, so neither id is knowable when the pointer is written. It does not need to be. Whoever reads the metadata got there by holding the token, so they already have the id to substitute.

The call

The response carries Access-Control-Allow-Origin: * and Cache-Control: public, max-age=4. Four seconds is the poller’s interval, so a cached answer is never more than one cycle stale. An unknown share token returns 404 with {"error":{"code":"SHARE_NOT_TRACKED","message":"No facility on this service issues that share token","retryable":false}}.

Valuing a pledge

Pass units as a query parameter to have the server compute the pledge value for you:
This adds a pledge block to the same document:
units is the unit count; the other three are integer drops as strings. 1,000,000 units of this facility are worth 0.803922 XRP, not the 1.000000 XRP a naive reader reports. The overstatement — 0.196078 XRP — is the part a haircut does not cover: a haircut absorbs volatility, not a misstatement. To price the pledge client-side instead:
Number("803922") happens to work. Number() on a 19 significant-digit XRPL NUMBER does not, and it fails silently. Keep monetary values as strings end to end.

Checking assessment.advisory

The document carries two numbers that deliberately disagree:
where A is Vault.AssetsTotal, L is Vault.LossUnrealized, and S is MPTokenIssuance.OutstandingAmount. Handing over only the honest number asks the consumer to take Orma’s word for it. Handing over both — with the gap named in basis points — shows what the obvious computation would have told them. assessment is labelled advisory: true on purpose. unitValue is a measurement; the grade is an opinion. They are separated so a consumer can take one without the other. When assessment is null, the score could not be derived — but the valuation is still served.

Handling resolved: false

Not every token resolves to a valuation. Opaque collateral is a legitimate answer. GET /api/mpt/:mptId/resolve runs the same loop the second-lender example uses and reports every step, including failures:
When resolved is false, treat the pledge as unpriced rather than zero — those are different answers. valuePledge returns an explicit unpriced string instead of throwing, and resolveFromIssuance reports every failing step.

Resolving from the token alone

If the agent does not know Orma’s base URL, it can walk the pointer itself. Three steps, all of them on the ledger or on a URL the ledger names:
Two rules the resolve module follows — and that any agent following a third-party pointer should copy: only http: and https: are followed, and each fetch is bounded at 2500 ms with an AbortController. The URL was written by someone else.

The schema field and the stability promise

Every valuation document carries "schema": "orma.nav/1". That contract is effectively permanent — not out of politeness, but because the URL is written into write-once token metadata. There is no migration path: no redirect Orma can publish, no version bump it can push to holders, no way to tell an existing token that the shape moved. An agent can hard-code nav.unitValue.held and expect it to be there. It should still treat unknown fields as additive rather than erroring on them.
assessment.methodVersion ("1.0.0") versions the grading opinion, not the wire format. The grade can be recalibrated without touching the schema. health.contractVersion versions the wider API, which has no such write-once constraint.

Part 2: Building on XRPL with a Coding Assistant

Everything below has been written wrong at least once in this codebase. Each one compiles, runs, and produces a plausible number.
XRPL NUMBER fields carry up to 19 significant digits and can arrive in scientific notation ("1e17"). Number() destroys them silently, parseFloat the same, and BigInt("1e17") throws. Use decimal.js at precision 40.
Also: rippled omits any field whose value equals the type default. A vault with no unrealised loss has no LossUnrealized key at all, not "0". Coalesce absent to zero in one helper so no call site can forget:
rippled omits from PreviousFields any field whose previous value equalled the type default. LossUnrealized defaults to 0, so the first impairment of a healthy vault emits PreviousFields: {} — a ModifiedNode that reads as touched but unchanged. Under cash-basis accounting (LEVersion = 1) impairment changes nothing else on the Vault object.Transaction 075FE6D2E0F29919AF477A2A8F581A680805A006138BB967D8434611E49229C3 on Devnet is that case. An indexer diffing metadata reports no change across a 19.61% fall in net asset value. Always re-read the full object. Orma polls full state every 4,000 ms for this reason.The same rule hides the first non-zero DebtTotal, the first CoverAvailable, and the first AssetsTotal. There is a matching trap at the other end of the lifecycle: tfLoanDefault deletes PrincipalOutstanding, TotalValueOutstanding, PaymentRemaining and NextPaymentDueDate from the Loan object, so loan.PrincipalOutstanding after a default is undefined, not "0".
Under LendingProtocolV1_1, a loan broker may only be attached to a closed-ended vault. This rule appears nowhere in the published XLS-65 or XLS-66 standards. It is enforced by LoanBrokerSet.cpp, and the only written-down source is the C++ source itself.The result code is tecNO_PERMISSION — the same code returned when you are not the vault owner. There is no way to distinguish the two from the result code alone.Critically, VaultKind is immutable. VaultSet rejects it with "Field 'VaultKind' found in disallowed location." There is no conversion. A developer who creates an open-ended vault and then tries to attach a broker must tear down and rebuild.
An open-ended vault returns tecNO_PERMISSION on LoanBrokerSet regardless of who submits it.
This is the finding the whole project is built on. The first impairment of a healthy vault moves LossUnrealized from 0 (the type default) to a positive integer. Because rippled omits fields at their type default from PreviousFields, the resulting metadata node reads:
A diff-based indexer sees a node touched but unchanged. It is the only transition that behaves this way — un-impairment and a second impairment both show correct diffs. The healthy→distressed transition, the single most important credit event the protocol has, is the one that is invisible.The fix is to re-read the full Vault SLE after every LoanManage and coalesce absent fields to zero.
LoanSet requires two signatures: the lender’s and the borrower’s. autofill() computes the fee for one signature. The counterparty signature is a second signing operation that consumes additional fee. autofill warns about this but does not pay for it.Double the fee before signing, or the transaction fails at submission with an insufficient fee error:
This was fixed in xrpl@5.2.0 for SIGNING_ENCODERS, but the fee calculation still requires the manual doubling.
Reading the Vault ledger entry to check whether a vault is gated returns nothing, which reads exactly like “open to everyone”. DomainID lives on the share MPTokenIssuance.
tfVaultPrivate (0x00010000) is required on VaultCreate alongside DomainID. A DomainID without the flag gates nothing. The Orma API reports that case explicitly rather than showing it as protected.
LoanManage carries LoanID only. It has no LoanBrokerID field on any flag. An impairment does not touch the LoanBroker object at all.A history filter that matches on the broker node or on tx.LoanBrokerID drops every impairment silently. In Orma’s original implementation this inverted a reputation rule: brokers who impaired before defaulting looked like brokers who never warned anyone, and the rule written to reward disclosure penalised it instead.The Loan node in the same metadata closes the join at no extra request:
Related: on a default, the ledger deletes PrincipalOutstanding from the loan, so FinalFields carries nothing and the obvious read returns zero. Fall back to PreviousFields.PrincipalOutstanding, then to the broker’s book movement.
A loan can carry more than one flag simultaneously. Testing with equality (loan.Flags === 131072) misses every loan that carries two flags.
Also: both cover rates are in 10510^{-5} units — divide both by 10510^5. At CoverRateMinimum = CoverRateLiquidation = 10000 the coefficient is 0.10×0.10=0.010.10 \times 0.10 = 0.01, not 0.100.10. The result is ceilinged, and the base is the broker’s total DebtTotal, not the defaulting loan’s principal.

The checklist, ready to paste

If a coding assistant produces ledger-reading code that has not been run against a live node, treat it as unverified. Multiple research agents have produced contradictory results on this protocol, and only live execution on a real node settles it. Always verify generated code against Devnet before relying on it.