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

# XRPL Lending Protocol: Key Findings for Developers

> Seven verified findings from building on XLS-65 and XLS-66 on Devnet — the traps that cost hours, each with a transaction hash and a fix.

Orma exists because a number that should be easy to read turned out not to be readable at all by the obvious method. Getting from there to a working measurement, publication, and enforcement layer meant originating real loans on Devnet, servicing them, impairing them, defaulting them, liquidating first-loss cover, and reading every result back. This page is what that cost — seven findings in the order you meet them as a developer, each verified against the live protocol with a transaction hash.

| Environment     | Detail                                                                                     |
| --------------- | ------------------------------------------------------------------------------------------ |
| Network         | XRPL **Devnet**, `wss://s.devnet.rippletest.net:51233`, `network_id` **2**                 |
| Server          | rippled **3.4.0-rc5**, 89 amendments including `LendingProtocolV1_1` and `fixCleanup3_4_0` |
| Libraries       | `xrpl` **5.2.0** with `ripple-binary-codec` **2.11.0**, Node **24.13.0**                   |
| Re-verification | Every finding below re-verified live on 2026-09-12                                         |

<Note>
  This runs on Devnet because it has to. `SingleAssetVault`, `LendingProtocol`, and `LendingProtocolV1_1` are not on Mainnet. XLS-47 Price Oracle **is** live on Mainnet, so the publication rail is the one component with a production path today. The measurement and the gate both read or bind a vault object, so neither is deployable until the lending amendments ship.
</Note>

### Severity rubric

| Level  | Meaning                                                                                                                                                                         |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **P0** | No supported path exists in the published tooling, or the documented path cannot succeed. The cost is a lost day, and the developer has no way to know the fault is not theirs. |
| **P1** | Costs hours and has a non-obvious workaround. Silent wrong-value bugs live here regardless of how small the fix is — the cost is the hours before you know you have a problem.  |
| **P2** | Friction, or a documentation inaccuracy. You lose minutes, or you are told something untrue and find out cheaply.                                                               |

***

## Findings

<Accordion title="1. LoanBroker requires a closed-ended vault — and no standard states it (P0)">
  **What you expect.** `LoanBrokerSet` requires the submitter to be the vault owner. You create a vault, you own it, you attach a broker.

  **What actually happens.** `tecNO_PERMISSION`, on a vault you own. The missing rule: under `LendingProtocolV1_1`, a loan broker may only be attached to a **closed-ended** vault. The proof by exhaustion, holding the owner constant and varying only the vault kind:

  | Submitter       | Vault kind       | Result                 | Hash                                                               |
  | --------------- | ---------------- | ---------------------- | ------------------------------------------------------------------ |
  | Non-owner       | Open-ended       | `tecNO_PERMISSION`     | `A260515D…`                                                        |
  | Non-owner       | Closed-ended     | `tecNO_PERMISSION`     | `FCF6DC50…`                                                        |
  | **Vault owner** | **Open-ended**   | **`tecNO_PERMISSION`** | `86AC8182A100EEDA32495706C06CDAC6D522B0E0772F5F65696064B6F3C0E5ED` |
  | **Vault owner** | **Closed-ended** | **`tesSUCCESS`**       | `CFC576DF9DCF7DB12059C559F93BE6F6094011820796859A1E814586C0E4AD5F` |

  The rule is in the C++ source. `LoanBrokerSet.cpp` lines 149–161 carry both the check and a comment explaining exactly the confusion it causes, ending in `JLOG(ctx.j.warn()) << "LoanBroker requires a closed-ended Vault."`. rippled computes that sentence and then discards it.

  Across the published `XLS-0065`, `XLS-0065/65.1`, and `XLS-0066` documents on `master`, the strings `VaultKind`, `SubscriptionDate`, `RedemptionDate`, `LEVersion`, and "closed-ended" each appear **zero times**. The closed-ended vault is specified in `XRPL-Standards` PR #587 (open since 2026-07-21), cash-basis accounting in PR #582 (2026-07-16). Library maintainers can read them; application developers cannot.

  **What it costs you.** `VaultKind` is **immutable**. `VaultSet` rejects it at deserialisation with `"Field 'VaultKind' found in disallowed location."` There is no conversion path. If you follow the published documentation, create an open-ended vault, take deposits into it, and then discover the lending layer is unavailable — you rebuild from scratch.

  **Cheapest fix.** Add an eleventh protocol-level failure condition to XLS-66 §3.3.3.2: *"`Vault(VaultID).VaultKind` is not `ClosedEnded` (`tecNO_PERMISSION`), requires `LendingProtocolV1_1`."* The behaviour is already agreed (`rippled#8076`, merged 2026-08-26) and already documented in the open `xrpl-dev-portal#3923`, which targets `release-3.4.0` and is not on xrpl.org yet.

  **Your fix today.** Always set `VaultKind: 1`, `SubscriptionDate`, `RedemptionDate`, and `LEVersion: 1` on `VaultCreate`.
</Accordion>

<Accordion title="2. The first impairment is invisible to metadata diffs (P1)">
  **What you expect.** `Vault.LossUnrealized` moves from 0 to 10,000,000 drops. Transaction metadata records changes, so an indexer that diffs `PreviousFields` against `FinalFields` — the standard indexer pattern — sees it.

  **What actually happens.** `PreviousFields` is an empty object. Verbatim, from `LoanManage` with `tfLoanImpair`, transaction `075FE6D2E0F29919AF477A2A8F581A680805A006138BB967D8434611E49229C3` (the Meridian facility, Devnet):

  ```json theme={null}
  {
    "ModifiedNode": {
      "FinalFields": {
        "LossUnrealized": "10000000",
        "AssetsTotal": "51000000",
        "AssetsAvailable": "41000000"
      },
      "LedgerEntryType": "Vault",
      "LedgerIndex": "...",
      "PreviousFields": {}
    }
  }
  ```

  rippled omits from `PreviousFields` any field whose previous value equalled the type default. `LossUnrealized` defaults to 0. Under cash-basis accounting an impairment changes nothing else on the Vault — `AssetsTotal` and `AssetsAvailable` are untouched. The result is a `ModifiedNode` that reads as touched but unchanged.

  | Reading                                              | Value         |
  | ---------------------------------------------------- | ------------- |
  | `AssetsTotal / OutstandingAmount`                    | **1.000000**  |
  | `(AssetsTotal - LossUnrealized) / OutstandingAmount` | **0.803922**  |
  | Divergence                                           | **1,961 bps** |

  A metadata-diffing indexer reports no change across a 19.61% fall in net asset value.

  The failure is asymmetric, which is why it survives testing:

  | Event                                      | Previous value   | `PreviousFields`                | Visible to a diff |
  | ------------------------------------------ | ---------------- | ------------------------------- | ----------------- |
  | First impairment of a healthy vault        | 0 (type default) | `{}`                            | **No**            |
  | `tfLoanUnimpair`                           | 10000000         | `{"LossUnrealized":"10000000"}` | Yes               |
  | Second impairment while one is outstanding | 2000000          | `{"LossUnrealized":"2000000"}`  | Yes               |

  An indexer author who tests the reversal or a second impairment sees correct metadata and concludes everything works. The only broken case is the transition from healthy to distressed.

  This was reported before us as `rippled#6487`, by the operator of the XRPLWin explorer — precisely the metadata-diffing indexer author this trap is built for. He closed it himself the same day: *"Not a bug."* No maintainer replied. The behaviour is unchanged on `3.4.0-rc5`.

  **Your fix.** Never diff. Re-read the full `Vault` SLE after every `LoanManage`. Coalesce absent fields to zero in one place:

  ```js theme={null}
  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="3. LoanManage carries no LoanBrokerID — every impairment drops from naive filters (P1)">
  **What you expect.** To assess a manager, you walk `account_tx` for the broker owner and keep transactions that concern this broker. A natural filter: keep transactions where `tx.LoanBrokerID` matches, or where the `LoanBroker` node appears in `AffectedNodes`.

  **What actually happens.** `LoanManage` carries `LoanID` only. It has no `LoanBrokerID` field on any flag. An impairment does not touch the `LoanBroker` object at all. The complete `AffectedNodes` set for an impairment on a funded vault with a broker and cover in place:

  | Node           | Type          |
  | -------------- | ------------- |
  | `ModifiedNode` | `Loan`        |
  | `ModifiedNode` | `AccountRoot` |
  | `ModifiedNode` | `Vault`       |

  A filter on either the transaction field or the broker node returns cover deposits and defaults — and **silently drops every impairment**. The record comes back complete, in the right order, with correct figures, and missing precisely the events that show a manager behaving well.

  In Orma's original conduct assessment this inverted a reputation rule: brokers who impaired before defaulting looked like brokers who never warned anyone. No exception was thrown. No result code was wrong. The assessment returned a plausible, reviewer-acceptable history that quietly penalised disclosure and rewarded concealment.

  **Your fix.** The join is free. The `Loan` node in the same metadata carries `FinalFields.LoanBrokerID`:

  ```js theme={null}
  const loanNode = nodes.find((n) => n.LedgerEntryType === 'Loan')
  const loanBroker = loanNode?.FinalFields?.LoanBrokerID ?? loanNode?.PreviousFields?.LoanBrokerID

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

  Note also: 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="4. Pin xrpl@5.2.0 — VaultCreate fails on earlier versions (P1)">
  **What you expect.** `npm install xrpl` installs a library that can serialise a closed-ended `VaultCreate` with `VaultKind`, `SubscriptionDate`, and `RedemptionDate`.

  **What actually happens.** For 17 days before the event, installing `xrpl` brought a version whose codec could not serialise those fields. `validate()` and `autofill()` both passed the transaction. The failure surfaced as an opaque codec error inside `Wallet.sign()` — no result code, no field name, no amendment name.

  `ripple-binary-codec@2.11.0` fixed this on 2026-09-11, and `xrpl@5.2.0` followed hours later with the `SIGNING_ENCODERS` table fix that also enabled counterparty signing for `LoanSet`. Both reached every fresh `npm install` automatically, because the codec range floats.

  `xrpl-py` received neither fix. No published Python version can counterparty-sign a `LoanSet`, so there is no Python path to originate a loan on this protocol at time of writing.

  **Your fix.** Pin `"xrpl": "5.2.0"` and `"ripple-binary-codec": "2.11.0"` explicitly in `package.json`. Floating the range means the floor can move under you mid-session.
</Accordion>

<Accordion title="5. LoanSet fee must be doubled by hand — autofill doesn't pay for the counterparty signature (P1)">
  **What you expect.** `autofill()` computes the correct fee for `LoanSet`.

  **What actually happens.** `LoanSet` requires two signatures — the lender's and the borrower's. `autofill()` computes the fee for one. The counterparty signature is a second signing operation that consumes additional fee. `autofill` warns about this but does not pay for it. The transaction fails at submission with an insufficient-fee error.

  **Your fix.** Double the fee before signing:

  ```js theme={null}
  const prepared = await client.autofill(tx)
  prepared.Fee = String(Number(prepared.Fee) * 2)

  // Sign by both parties
  const signed = wallet.sign(prepared)
  const countersigned = counterpartyWallet.sign(signed.tx_blob, true)
  await client.submitAndWait(countersigned.tx_blob)
  ```
</Accordion>

<Accordion title="6. OracleSet must bypass validate() — it rejects legal scale values (P1)">
  **What you expect.** `validate()` accepts a well-formed `OracleSet` transaction.

  **What actually happens.** `xrpl.js` `validate()` rejects `OracleSet` transactions carrying valid `Scale` values for non-price dimensions. The six Orma dimensions use `AssetClass: "risk"` and `Scale` values of 6 for `NAV` and 4 for the other five. `validate()` refuses them.

  Pass the transaction directly to the signing layer without calling `validate()`:

  ```js theme={null}
  // Do not call validate() — it rejects legal Scale values for risk dimensions
  const signed = wallet.sign(tx)
  await client.submitAndWait(signed.tx_blob)
  ```

  Three additional `OracleSet` traps that corrupt data silently instead of erroring:

  * **Not a merge.** A `(BaseAsset, QuoteAsset)` pair already on the object but omitted from the transaction is **kept with its `AssetPrice` stripped**. Publishing one dimension blanks the other five. Always send the full six-dimension series.
  * **`LastUpdateTime` must strictly increase.** Equal or lower burns a fee and a sequence number. One update per second per object, maximum.
  * **`AssetPrice` is hexadecimal.** Writing `"100"` means 256. Use `BigInt(n).toString(16).toUpperCase()` to encode and `BigInt("0x" + hex)` to decode.

  Reusing an `OracleDocumentID` for a different set of assets accumulates stale pairs until the series exceeds the ledger ceiling and `OracleSet` returns `tecARRAY_TOO_LARGE`. At that point nothing can be published to that document and the only recovery is delete and recreate.
</Accordion>

<Accordion title="7. DomainID lives on MPTokenIssuance, not on Vault (P2)">
  **What you expect.** Reading the `Vault` ledger entry tells you whether a vault is gated, because `DomainID` is a vault property.

  **What actually happens.** `DomainID` is not stored on the `Vault` ledger entry. It lives on the share `MPTokenIssuance`. Reading the Vault to check whether it is gated returns nothing — which reads exactly like "open to everyone".

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

  // Right: read the share issuance via Vault.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 at all. The Orma API reports that case explicitly as `"carries a domain but is not flagged private, so nothing is enforced"` rather than presenting it as protected.

  Use `GET /api/vaults/:vaultId/gate` to verify a gate setup — it reads the issuance, not the Vault.
</Accordion>

***

<Note>
  The full register covers **40 findings, 7 P0, 19 P1, 14 P2**, plus three withdrawn after re-verification (listed rather than deleted). Every claim carries a transaction hash, a file and line, a verbatim error string, or a registry timestamp. Every hash resolves at `https://devnet.xrpl.org/transactions/<hash>`.
</Note>

## What was not tested

Stated so no one builds on claims that were not made.

* **Non-XRP vault assets.** Everything above uses XRP, so `Vault.Scale` is 0 and all arithmetic is integer drops. With an IOU or MPT asset and a non-zero scale, precision and dust paths become active and several exact numbers will shift.
* **The `min()` clamps in the cover formula.** In the four defaults tested, the double product was always far below both `DefaultAmount` and `CoverAvailable`, so only the first term ever bound.
* **`LoanDelete`, `LoanBrokerDelete`, `VaultDelete`, `LoanBrokerCoverClawback`, `tfLoanFullPayment`**, and the fee and rate fields attached to loan closure.
* **`OracleSet` under a loaded ledger or a regular-key account.** Devnet `load_factor` was 1 throughout.
