> ## 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.

# Consuming Orma from Autonomous Agents and Coding Assistants

> An agent holding vault shares needs no API key, no prior relationship, and no documentation — the token itself names the valuation endpoint. Here's how.

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](/nav-pointer):

```json theme={null}
"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"
}
```

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

```js theme={null}
const BASE = 'http://localhost:8787'
const MPT = '000000014D667775372D5B78E07FFF294678C7F9CE82AFBC'

const res = await fetch(`${BASE}/api/mpt/${MPT}/nav`, {
  headers: { accept: 'application/json' },
})
const nav = await res.json()

// Guard before using it. A 200 is not proof of a valuation: an early version of
// the pointer route resolved to the diagnostic instead, returned a perfectly good
// 200 with no unitValue on it, and crashed the caller.
if (nav.schema !== 'orma.nav/1' || !nav.unitValue?.held) {
  throw new Error('not a valuation document')
}

nav.unitValue.held           // "0.803922"  ← the number to lend against
nav.unitValue.reported       // "1.000000"  ← what a metadata-diffing indexer shows
nav.unitValue.divergenceBps  // 1961
nav.asOf                     // "2026-09-12T22:20:22Z"
nav.ledgerIndex              // 5263343
```

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:

```
GET /api/mpt/000000014D667775372D5B78E07FFF294678C7F9CE82AFBC/nav?units=1000000
```

This adds a `pledge` block to the same document:

```json theme={null}
"pledge": {
  "units": "1000000",
  "valueHeld": "803922",
  "valueReported": "1000000",
  "overstatement": "196078"
}
```

`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:

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

const units = new Decimal('1000000')
const held = units.times(nav.unitValue.held)        // 803922 drops
const reported = units.times(nav.unitValue.reported)
const overstatement = reported.minus(held)           // 196078 drops

// A lender's own discount applies to the HELD value, never to the reported one.
const haircut = new Decimal('0.15')
const lendable = held.times(new Decimal(1).minus(haircut))
```

<Warning>
  `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.
</Warning>

### Checking `assessment.advisory`

The document carries two numbers that deliberately disagree:

```
NAV_naive = A / S
NAV_held  = (A - L) / S
```

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:

```json theme={null}
"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" }
]
```

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:

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

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

// 1. Read the MPTokenIssuance
const { result } = await client.request({
  command: 'ledger_entry',
  mpt_issuance: MPT,
})
const node = result.node

// 2. Decode the metadata
const meta = JSON.parse(Buffer.from(node.MPTokenMetadata, 'hex').toString('utf8'))

// 3. Substitute this token's own id into the template, then follow it
const url = meta.orma.nav_url.replaceAll(meta.orma.nav_url_param, MPT)
const nav = await fetch(url, { headers: { accept: 'application/json' } }).then((r) => r.json())

await client.disconnect()
```

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.

| Change                          | Allowed |
| ------------------------------- | ------- |
| Add a field                     | Yes     |
| Add a value to an existing enum | Yes     |
| Rename a field                  | Never   |
| Change a field's type           | Never   |
| Remove a field                  | Never   |
| Change what a field means       | Never   |

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.

<Note>
  `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.
</Note>

***

## 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.

<Accordion title="Money is a decimal string — never Number() or parseFloat">
  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.

  ```js theme={null}
  // Wrong
  const assets = Number(vault.AssetsTotal)

  // Right
  import Decimal from 'decimal.js'
  Decimal.set({ precision: 40 })
  const assets = new Decimal(vault.AssetsTotal ?? 0)
  ```

  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:

  ```js theme={null}
  /** Read a ledger amount. Absent, null, empty string and undefined all mean zero. */
  export function num(v) {
    if (v === undefined || v === null || v === '') return new Decimal(0)
    if (v instanceof Decimal) return v
    return new Decimal(String(v))
  }
  ```
</Accordion>

<Accordion title="Absent means zero — rippled omits fields equal to the type default">
  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"`.
</Accordion>

<Accordion title="LoanBroker requires a closed-ended vault — VaultKind: 1 is not optional">
  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.

  ```js theme={null}
  // A closed-ended vault requires these three fields at VaultCreate
  {
    TransactionType: 'VaultCreate',
    VaultKind: 1,              // 1 = closed-ended (required for LoanBroker)
    SubscriptionDate: ...,     // Unix timestamp
    RedemptionDate: ...,       // Unix timestamp
    LEVersion: 1,              // cash-basis accounting
  }
  ```

  An open-ended vault returns `tecNO_PERMISSION` on `LoanBrokerSet` regardless of who submits it.
</Accordion>

<Accordion title="The first impairment is invisible to metadata diffs — poll full state">
  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:

  ```json theme={null}
  {
    "ModifiedNode": {
      "LedgerEntryType": "Vault",
      "FinalFields": { "LossUnrealized": "10000000", ... },
      "PreviousFields": {}
    }
  }
  ```

  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.
</Accordion>

<Accordion title="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:

  ```js theme={null}
  const prepared = await client.autofill(tx)
  prepared.Fee = String(Number(prepared.Fee) * 2)
  const signed = wallet.sign(prepared)
  const countersigned = counterpartyWallet.sign(signed.tx_blob, true) // multisign=true
  ```

  This was fixed in `xrpl@5.2.0` for `SIGNING_ENCODERS`, but the fee calculation still requires the manual doubling.
</Accordion>

<Accordion title="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:

  ```js theme={null}
  // Do not pass through validate() — it rejects legal Scale values for risk dimensions
  const encoded = encode(tx)  // ripple-binary-codec
  const signed = wallet.sign({ ...tx, SigningPubKey: wallet.publicKey }, { forMultiSign: false })
  ```

  Also: `AssetPrice` in `OracleSet` is a `UInt64` serialised as **hexadecimal**. Writing `"100"` means 256, not 100.

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

  `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.
</Accordion>

<Accordion title="DomainID lives on MPTokenIssuance, not on Vault">
  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`.

  ```js theme={null}
  // Wrong: DomainID is not a Vault field
  const domain = vaultNode.DomainID  // always undefined

  // Right: read the share issuance. The vault names it in ShareMPTID.
  const iss = await client.request({
    command: 'ledger_entry',
    mpt_issuance: vaultNode.ShareMPTID,
  })
  const domain = iss.result.node.DomainID
  ```

  `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.
</Accordion>

<Accordion title="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:

  ```js theme={null}
  const nodes = (meta.AffectedNodes ?? [])
    .map((n) => n.ModifiedNode ?? n.DeletedNode)
    .filter(Boolean)

  const brokerNode = nodes.find(
    (n) => n.LedgerEntryType === 'LoanBroker'
      && (n.LedgerIndex === brokerId || n.FinalFields?.index === brokerId),
  )
  const loanNode = nodes.find((n) => n.LedgerEntryType === 'Loan')
  const loanBroker = loanNode?.FinalFields?.LoanBrokerID ?? loanNode?.PreviousFields?.LoanBrokerID

  if (!brokerNode && tx.LoanBrokerID !== brokerId && loanBroker !== brokerId) continue
  ```

  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.
</Accordion>

<Accordion title="Test loan flags by bit mask, not equality">
  A loan can carry more than one flag simultaneously. Testing with equality (`loan.Flags === 131072`) misses every loan that carries two flags.

  ```js theme={null}
  const IMPAIRED  = 0x20000  // 131072
  const DEFAULTED = 0x10000  // 65536

  const isImpaired  = (Number(loan.Flags ?? 0) & IMPAIRED)  === IMPAIRED
  const isDefaulted = (Number(loan.Flags ?? 0) & DEFAULTED) === DEFAULTED
  ```

  Also: both cover rates are in $10^{-5}$ units — divide **both** by $10^5$. At `CoverRateMinimum = CoverRateLiquidation = 10000` the coefficient is $0.10 \times 0.10 = 0.01$, not $0.10$. The result is ceilinged, and the base is the broker's total `DebtTotal`, not the defaulting loan's principal.

  ```js theme={null}
  const RATE_SCALE = 100_000
  const c = new Decimal(broker.CoverRateMinimum).div(RATE_SCALE)
    .times(new Decimal(broker.CoverRateLiquidation).div(RATE_SCALE))
  const consumed = Decimal.min(
    new Decimal(broker.DebtTotal).times(c).ceil(),
    principalOfTheDefaultedLoan,
    new Decimal(broker.CoverAvailable),
  )
  ```
</Accordion>

### The checklist, ready to paste

```md theme={null}
## XLS-65 / XLS-66 rules (non-negotiable)

1. Monetary values are decimal strings up to 19 significant digits. Use decimal.js at
   precision 40. Never Number(), never parseFloat, never BigInt() on a value that may
   be in scientific notation.
2. An absent ledger field means zero, not missing. rippled omits type defaults.
3. Never diff PreviousFields. It omits any field whose previous value was the default,
   so the first impairment of a healthy vault emits PreviousFields: {}. Re-read the
   full object.
4. LoanBroker requires a closed-ended vault: VaultKind: 1, plus SubscriptionDate and
   RedemptionDate. VaultKind is immutable. An open-ended vault returns tecNO_PERMISSION
   on LoanBrokerSet regardless of who submits it.
5. DomainID lives on the share MPTokenIssuance, not on the Vault. tfVaultPrivate
   (0x00010000) is required alongside it, or nothing is gated.
6. LoanManage carries LoanID only — no LoanBrokerID — and an impairment does not touch
   the LoanBroker object. Join through Loan.FinalFields.LoanBrokerID in the same
   metadata.
7. A defaulted loan loses PrincipalOutstanding from FinalFields. Read PreviousFields.
8. CoverRateMinimum and CoverRateLiquidation are both 1e-5 units. Divide BOTH by 1e5.
   Ceil the product. The base is LoanBroker.DebtTotal, not the defaulted principal.
9. Test loan Flags by bit (& 0x20000), never by equality.
10. OracleSet is not a merge: an omitted pair is kept with its price stripped. Send
    every PriceData entry every time. LastUpdateTime must strictly increase, is UNIX
    epoch, and must never be in the future. AssetPrice is hex.
11. MPTokenMetadata is write-once: settable on VaultCreate, rejected on VaultSet,
    tecNO_PERMISSION on MPTokenIssuanceSet. Max 1024 bytes. Put a pointer there, not
    a number.
12. Double the LoanSet fee by hand before signing. autofill does not account for the
    counterparty signature.
```

<Tip>
  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.
</Tip>
