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 atVaultCreate 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:
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
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
Passunits as a query parameter to have the server compute the pledge value for you:
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:
Checking assessment.advisory
The document carries two numbers that deliberately disagree:
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:
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: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.
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.Money is a decimal string — never Number() or parseFloat
Money is a decimal string — never Number() or parseFloat
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.LossUnrealized key at all, not "0". Coalesce absent to zero in one helper so no call site can forget:Absent means zero — rippled omits fields equal to the type default
Absent means zero — rippled omits fields equal to the type default
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".LoanBroker requires a closed-ended vault — VaultKind: 1 is not optional
LoanBroker requires a closed-ended vault — VaultKind: 1 is not optional
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.tecNO_PERMISSION on LoanBrokerSet regardless of who submits it.The first impairment is invisible to metadata diffs — poll full state
The first impairment is invisible to metadata diffs — poll full state
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:Vault SLE after every LoanManage and coalesce absent fields to zero.LoanSet fee must be doubled by hand — autofill doesn't cover counterparty signature
LoanSet fee must be doubled by hand — autofill doesn't cover counterparty signature
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:xrpl@5.2.0 for SIGNING_ENCODERS, but the fee calculation still requires the manual doubling.OracleSet signed raw — validate() rejects legal scale values
OracleSet signed raw — validate() rejects legal scale values
xrpl.js validate() rejects OracleSet transactions that carry valid scale values for non-price dimensions. Bypass the model layer and sign raw:AssetPrice in OracleSet is a UInt64 serialised as hexadecimal. Writing "100" means 256, not 100.LastUpdateTime must strictly increase (equal or lower is rejected), is UNIX epoch (not Ripple epoch), and must never be in the future — a clock-skewed laptop will brick the oracle until wall clock catches up. OracleSet is not a merge: a pair omitted from the transaction is kept with its price stripped. Always send the full set of PriceData entries you want the object to hold.DomainID lives on MPTokenIssuance, not on Vault
DomainID lives on MPTokenIssuance, not on Vault
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 has no LoanBrokerID — join via Loan.FinalFields.LoanBrokerID
LoanManage has no LoanBrokerID — join via Loan.FinalFields.LoanBrokerID
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: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.Test loan flags by bit mask, not equality
Test loan flags by bit mask, not equality
loan.Flags === 131072) misses every loan that carries two flags.CoverRateMinimum = CoverRateLiquidation = 10000 the coefficient is , not . The result is ceilinged, and the base is the broker’s total DebtTotal, not the defaulting loan’s principal.